diff --git a/.claude/skills/add-model/SKILL.md b/.claude/skills/add-model/SKILL.md index 0fa5783c8..4c5c7cdaa 100644 --- a/.claude/skills/add-model/SKILL.md +++ b/.claude/skills/add-model/SKILL.md @@ -478,9 +478,9 @@ class MyModelTestCase(ViewTestCases.PrimaryObjectViewTestCase): **File:** `netbox//tests/test_filtersets.py` ```python -from utilities.testing import ChangeLoggedFilterSetTests +from utilities.testing import ChangeLoggedFilterSetTestMixin -class MyModelFilterSetTestCase(TestCase, ChangeLoggedFilterSetTests): +class MyModelFilterSetTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = MyModel.objects.all() filterset = MyModelFilterSet @@ -496,7 +496,7 @@ class MyModelFilterSetTestCase(TestCase, ChangeLoggedFilterSetTests): # Test FK and FK_id filters ``` -`ChangeLoggedFilterSetTests` provides standard tests for `id`, `created`, `last_updated`, `q` search, etc. Always mix it in. +`ChangeLoggedFilterSetTestMixin` provides standard tests for `id`, `created`, `last_updated`, `q` search, etc. Always mix it in. ## Common Gotchas diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 87ca00c8a..b656b9553 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,6 +93,20 @@ jobs: - name: Check package metadata run: twine check dist/* + - name: Verify the release tag + # Both checks run here, in the unprivileged build job, against the wheel that becomes this + # run's artifact, so neither publish job has to check out the repository or execute its + # scripts while holding id-token: write. A failure here skips every downstream job. + if: startsWith(github.ref, 'refs/tags/v') + env: + TAG: ${{ github.ref_name }} + run: | + [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]] || { + echo "Ref '$TAG' is not a release tag of the form vX.Y.Z[-designation]" + exit 1 + } + python scripts/verify_release_tag.py "$TAG" dist/*.whl + - name: Upload package artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -309,15 +323,14 @@ jobs: name: Publish package to Test PyPI runs-on: ubuntu-latest needs: [smoke-test, cli-smoke-test, verify-dependencies, verify-sdist] - # Publishing always requires a v* tag ref: a tag push publishes to Test PyPI - # automatically, and a manual dispatch does the same when the chosen ref is a v* tag. - # Branch dispatches still run the build, verify, and smoke-test jobs (a useful dry run) - # but the publish job is skipped. Production PyPI publishing is intentionally absent - # during the v4.6.x preview; it arrives with the v4.7.0 feature branch. - # startsWith() only routes to this job (workflow `if:` expressions cannot regex-match); - # the exact tag format (v) is enforced below by the "Enforce - # release tag format" step and scripts/verify_release_tag.py before any upload. - if: startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + # Test PyPI remains an opt-in rehearsal channel: only a manual dispatch from a v* tag publishes + # here, so the publish path can be exercised against a real index without touching production. + # A branch dispatch still runs the build, verify, and smoke-test jobs as a dry run, with both + # publish jobs skipped. + # startsWith() is only a coarse route to this job; workflow if: expressions cannot regex-match. + # The tag format and the tag-to-wheel version match are enforced in the build job, which fails + # the whole run before anything is uploaded. + if: github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/v') environment: name: testpypi url: https://test.pypi.org/p/netbox @@ -326,38 +339,49 @@ jobs: id-token: write steps: - - name: Enforce release tag format - env: - TAG: ${{ github.ref_name }} - run: | - [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]] || { - echo "Ref '$TAG' is not a release tag of the form vX.Y.Z[-designation]" - exit 1 - } - - - name: Check out repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: '3.12' - - name: Install tooling - run: python -m pip install --upgrade pip packaging - - name: Download package artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: python-package-distributions path: dist/ - - name: Verify the git tag matches the built version - run: python scripts/verify_release_tag.py "${{ github.ref_name }}" dist/*.whl - - name: Publish package distributions to Test PyPI # Bundles twine 7.0.0 and packaging 26.2 (requirements/runtime.txt). # Keep EXPECTED_TWINE_VERSION and EXPECTED_PACKAGING_VERSION aligned when updating this action. uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: repository-url: https://test.pypi.org/legacy/ + print-hash: true + + publish-pypi: + name: Publish package to PyPI + runs-on: ubuntu-latest + needs: [smoke-test, cli-smoke-test, verify-dependencies, verify-sdist] + # A v* tag push is the production path. Test PyPI is an opt-in rehearsal rather than a promotion + # stage, so it is deliberately absent from this job's needs: an outage, a duplicate filename, or + # a misconfiguration on a test service must not block a verified production release. The four + # verification jobs above already ran against these exact artifacts. The protected pypi + # environment supplies the deliberate approval step, and because accepted PyPI filenames cannot + # be replaced or reused, a filename the index already holds fails the job instead of being + # skipped. + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + environment: + name: pypi + url: https://pypi.org/p/netbox + permissions: + contents: read + id-token: write + + steps: + - name: Download package artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-package-distributions + path: dist/ + + - name: Publish package distributions to PyPI + # Bundles twine 7.0.0 and packaging 26.2 (requirements/runtime.txt). + # Keep EXPECTED_TWINE_VERSION and EXPECTED_PACKAGING_VERSION aligned when updating this action. + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + print-hash: true diff --git a/base_requirements.txt b/base_requirements.txt index 96f7f5a82..08d1e4b9c 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -4,7 +4,7 @@ colorama # The Python web framework on which NetBox is built # https://docs.djangoproject.com/en/stable/releases/ -Django==6.0.* +Django==6.1.* # Django middleware which permits cross-domain API requests # https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst @@ -27,12 +27,15 @@ django-graphiql-debug-toolbar django-htmx # Modified Preorder Tree Traversal (recursive nesting of objects) +# Retained primarily for plugin backward compatibility: the deprecated +# NestedGroupModel base remains MPTT-backed for plugins still using it. Also +# required by historical migrations that pre-date the switch to PostgreSQL ltree. +# NetBox core runtime uses netbox.models.ltree.LtreeModel instead. django-mptt -# Context managers for PostgreSQL advisory locks -# https://github.com/Xof/django-pglocks/blob/main/CHANGELOG.md -# django-pglocks has been merged into django-pgware (see #22571) -django-pglocks==1.0.4 +# Context managers for PostgreSQL advisory locks (successor to django-pglocks) +# https://github.com/Xof/django-pgware +django-pgware # Prometheus metrics library for Django # https://github.com/korfuri/django-prometheus/blob/master/CHANGELOG.md @@ -58,8 +61,7 @@ django-storages # Abstraction models for rendering and paginating HTML tables # https://github.com/jieter/django-tables2/blob/master/CHANGELOG.md -# See #21902 for upgrading to django-tables2 v2.9+ -django-tables2<2.9 +django-tables2 # User-defined tags for objects # https://github.com/jazzband/django-taggit/blob/master/CHANGELOG.rst @@ -72,7 +74,7 @@ django-timezone-field # A REST API framework for Django projects # https://www.django-rest-framework.org/community/release-notes/ # TODO: Re-evaluate the monkey-patch of get_unique_validators() before upgrading -djangorestframework==3.17.1 +djangorestframework==3.18.0 # Sane and flexible OpenAPI 3 schema generation for Django REST framework. # https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst @@ -158,8 +160,7 @@ social-auth-app-django # Social authentication framework # https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md -# Need to verify that v4.9.0 does not introduce breaking changes (see #22095) -social-auth-core==4.8.* +social-auth-core # Image thumbnail generation # https://github.com/jazzband/sorl-thumbnail/blob/master/CHANGES.rst diff --git a/contrib/generated_schema.json b/contrib/generated_schema.json index 4e5eee344..54644009a 100644 --- a/contrib/generated_schema.json +++ b/contrib/generated_schema.json @@ -328,6 +328,7 @@ "virtual", "bridge", "lag", + "channel", "100base-fx", "100base-lfx", "100base-tx", diff --git a/contrib/openapi.json b/contrib/openapi.json index b4954bd1e..7cbf60b07 100644 --- a/contrib/openapi.json +++ b/contrib/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "NetBox REST API", - "version": "4.6.10", + "version": "4.7.0-beta2", "license": { "name": "Apache v2 License" } @@ -1005,6 +1005,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -1056,6 +1084,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -1107,6 +1155,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -1148,6 +1216,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -2533,6 +2631,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -2584,6 +2710,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -2635,6 +2781,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -2676,6 +2842,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -4880,6 +5076,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -4931,6 +5155,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -4982,6 +5226,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -5023,6 +5287,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -6521,6 +6815,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -6572,6 +6894,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -6623,6 +6965,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -6664,6 +7026,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -9052,6 +9444,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -9103,6 +9523,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -9154,6 +9594,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -9195,6 +9655,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -10606,6 +11096,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -10657,6 +11175,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -10708,6 +11246,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -10749,6 +11307,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -12084,6 +12672,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -12135,6 +12751,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -12186,6 +12822,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -12227,6 +12883,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -13792,6 +14478,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -13843,6 +14557,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -13894,6 +14628,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -13935,6 +14689,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -15157,6 +15941,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -15208,6 +16020,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -15259,6 +16091,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -15300,6 +16152,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -16798,6 +17680,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -16849,6 +17759,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -16900,6 +17830,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -16941,6 +17891,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -18522,6 +19502,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -18573,6 +19581,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -18624,6 +19652,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -18665,6 +19713,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -21990,6 +23068,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -22041,6 +23147,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -22092,6 +23218,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -22133,6 +23279,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -22453,6 +23629,33 @@ "format": "date-time" } }, + { + "in": "query", + "name": "execution_time", + "schema": { + "type": "string", + "format": "duration" + }, + "description": "Execution time" + }, + { + "in": "query", + "name": "execution_time__gte", + "schema": { + "type": "string", + "format": "duration" + }, + "description": "Execution time (minimum)" + }, + { + "in": "query", + "name": "execution_time__lte", + "schema": { + "type": "string", + "format": "duration" + }, + "description": "Execution time (maximum)" + }, { "in": "query", "name": "fields", @@ -26105,6 +27308,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -26156,6 +27387,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -26207,6 +27458,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -26248,6 +27519,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -29605,6 +30906,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -29656,6 +30985,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -29707,6 +31056,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -29748,6 +31117,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -31143,6 +32542,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -31194,6 +32621,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -31245,6 +32692,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -31286,6 +32753,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -33931,6 +35428,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -33982,6 +35507,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -34033,6 +35578,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -34074,6 +35639,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -35458,6 +37053,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -35509,6 +37132,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -35560,6 +37203,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -35601,6 +37264,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -38246,6 +39939,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -38297,6 +40018,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -38348,6 +40089,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -38389,6 +40150,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -38657,6 +40448,14044 @@ } } }, + "/api/dcim/cooling-feeds/": { + "get": { + "operationId": "dcim_cooling_feeds_list", + "description": "Get a list of cooling feed objects.", + "parameters": [ + { + "in": "query", + "name": "brief", + "schema": { + "type": "boolean" + }, + "description": "Return only brief fields for each object." + }, + { + "in": "query", + "name": "cooling_capacity", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capacity__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "cooling_capacity__gt", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capacity__gte", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capacity__lt", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capacity__lte", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capacity__n", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_source_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Cooling source (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_source_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Cooling source (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__empty", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__gt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__gte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__lt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__lte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created_by_request", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "description", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "description__ic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__ie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__iew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__iregex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__isw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__nic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__nie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__niew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__nisw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__regex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "fields", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of fields to include in the response. Example: `fields=id,name`." + }, + { + "in": "query", + "name": "id", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "id__gt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__gte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__lt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__lte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__empty", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__gt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__gte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__lt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__lte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "name": "limit", + "required": false, + "in": "query", + "description": "Number of results to return per page.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "max_flow", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "max_flow__gt", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow__gte", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow__lt", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow__lte", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow__n", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "max_flow_unit__ic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__ie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__iew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__iregex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__isw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__nic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__nie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__niew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__nisw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__regex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "modified_by_request", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "name", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "name__ic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__ie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__iew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__iregex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__isw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__nic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__nie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__niew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__nisw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__regex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "name": "offset", + "required": false, + "in": "query", + "description": "The initial index from which to return the results.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "omit", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of fields to exclude from the response. Example: `omit=description,tags`." + }, + { + "name": "ordering", + "required": false, + "in": "query", + "description": "Which field to use when ordering the results.", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "owner", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Owner (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Owner (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_group", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Owner Group (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_group__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Owner Group (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_group_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Owner Group (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_group_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Owner Group (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_id", + "schema": { + "type": "array", + "items": { + "type": "integer", + "nullable": true + } + }, + "description": "Owner (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "nullable": true + } + }, + "description": "Owner (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + }, + "description": "Search" + }, + { + "in": "query", + "name": "rack_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Rack (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "rack_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Rack (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "region", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "region__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "region_id", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "region_id__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Site name (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Site name (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site_group", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site_group__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site_group_id", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site_group_id__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Site (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Site (ID)", + "explode": true, + "style": "form" + }, + { + "name": "start", + "required": false, + "in": "query", + "description": "Cursor-based pagination: return results with pk >= start, ordered by pk. Mutually exclusive with offset.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ec530572dc778583" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "status__ic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ec530572dc778583" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__ie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ec530572dc778583" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__iew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ec530572dc778583" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__iregex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ec530572dc778583" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__isw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ec530572dc778583" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ec530572dc778583" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__nic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ec530572dc778583" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__nie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ec530572dc778583" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__niew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ec530572dc778583" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__nisw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ec530572dc778583" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__regex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ec530572dc778583" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tag", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tag__any", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tag__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tag_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tag_id__any", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tag_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tenant", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Tenant (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tenant__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Tenant (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tenant_group", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tenant_group__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tenant_group_id", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tenant_group_id__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tenant_id", + "schema": { + "type": "array", + "items": { + "type": "integer", + "nullable": true + } + }, + "description": "Tenant (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tenant_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "nullable": true + } + }, + "description": "Tenant (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "updated_by_request", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "tags": [ + "dcim" + ], + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedCoolingFeedList" + } + } + }, + "description": "" + } + } + }, + "post": { + "operationId": "dcim_cooling_feeds_create", + "description": "Post a list of cooling feed objects.", + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/WritableCoolingFeedRequest" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/WritableCoolingFeedRequest" + } + } + ] + } + }, + "multipart/form-data": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/WritableCoolingFeedRequest" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/WritableCoolingFeedRequest" + } + } + ] + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoolingFeed" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." + } + } + }, + "put": { + "operationId": "dcim_cooling_feeds_bulk_update", + "description": "Put a list of cooling feed objects.", + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BulkCoolingFeedRequest" + } + } + }, + "multipart/form-data": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BulkCoolingFeedRequest" + } + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingFeed" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." + } + } + }, + "patch": { + "operationId": "dcim_cooling_feeds_bulk_partial_update", + "description": "Patch a list of cooling feed objects.", + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PatchedBulkCoolingFeedRequest" + } + } + }, + "multipart/form-data": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PatchedBulkCoolingFeedRequest" + } + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingFeed" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." + } + } + }, + "delete": { + "operationId": "dcim_cooling_feeds_bulk_destroy", + "description": "Delete a list of cooling feed objects.", + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingFeedRequest" + } + } + }, + "multipart/form-data": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingFeedRequest" + } + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." + } + } + } + }, + "/api/dcim/cooling-feeds/{id}/": { + "get": { + "operationId": "dcim_cooling_feeds_retrieve", + "description": "Get a cooling feed object.", + "parameters": [ + { + "in": "query", + "name": "brief", + "schema": { + "type": "boolean" + }, + "description": "Return only brief fields for each object." + }, + { + "in": "query", + "name": "fields", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of fields to include in the response. Example: `fields=id,name`." + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this cooling feed.", + "required": true + }, + { + "in": "query", + "name": "omit", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of fields to exclude from the response. Example: `omit=description,tags`." + } + ], + "tags": [ + "dcim" + ], + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoolingFeed" + } + } + }, + "description": "" + } + } + }, + "put": { + "operationId": "dcim_cooling_feeds_update", + "description": "Put a cooling feed object.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this cooling feed.", + "required": true + } + ], + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WritableCoolingFeedRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/WritableCoolingFeedRequest" + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoolingFeed" + } + } + }, + "description": "" + } + } + }, + "patch": { + "operationId": "dcim_cooling_feeds_partial_update", + "description": "Patch a cooling feed object.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this cooling feed.", + "required": true + } + ], + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedWritableCoolingFeedRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedWritableCoolingFeedRequest" + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoolingFeed" + } + } + }, + "description": "" + } + } + }, + "delete": { + "operationId": "dcim_cooling_feeds_destroy", + "description": "Delete a cooling feed object.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this cooling feed.", + "required": true + } + ], + "tags": [ + "dcim" + ], + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + } + } + } + }, + "/api/dcim/cooling-intake-templates/": { + "get": { + "operationId": "dcim_cooling_intake_templates_list", + "description": "Get a list of cooling intake template objects.", + "parameters": [ + { + "in": "query", + "name": "brief", + "schema": { + "type": "boolean" + }, + "description": "Return only brief fields for each object." + }, + { + "in": "query", + "name": "created", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__empty", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__gt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__gte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__lt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__lte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created_by_request", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "description", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "description__ic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__ie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__iew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__iregex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__isw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__nic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__nie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__niew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__nisw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__regex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_type_id", + "schema": { + "type": "array", + "items": { + "type": "integer", + "nullable": true + } + }, + "description": "Device type (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_type_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "nullable": true + } + }, + "description": "Device type (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "diameter__gt", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter__gte", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter__lt", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter__lte", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter__n", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "diameter_unit__ic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__ie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__iew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__iregex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__isw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__nic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__nie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__niew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__nisw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__regex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "fields", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of fields to include in the response. Example: `fields=id,name`." + }, + { + "in": "query", + "name": "id", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "id__gt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__gte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__lt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__lte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "label__ic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__ie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__iew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__iregex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__isw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__nic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__nie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__niew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__nisw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__regex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__empty", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__gt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__gte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__lt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__lte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "name": "limit", + "required": false, + "in": "query", + "description": "Number of results to return per page.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "max_flow", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "max_flow__gt", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow__gte", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow__lt", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow__lte", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow__n", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "max_flow_unit__ic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__ie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__iew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__iregex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__isw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__nic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__nie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__niew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__nisw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__regex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "modified_by_request", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "module_type_id", + "schema": { + "type": "array", + "items": { + "type": "integer", + "nullable": true + } + }, + "description": "Module type (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "module_type_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "nullable": true + } + }, + "description": "Module type (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "name__ic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__ie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__iew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__iregex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__isw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__nic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__nie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__niew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__nisw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__regex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "name": "offset", + "required": false, + "in": "query", + "description": "The initial index from which to return the results.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "omit", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of fields to exclude from the response. Example: `omit=description,tags`." + }, + { + "name": "ordering", + "required": false, + "in": "query", + "description": "Which field to use when ordering the results.", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + }, + "description": "Search" + }, + { + "name": "start", + "required": false, + "in": "query", + "description": "Cursor-based pagination: return results with pk >= start, ordered by pk. Mutually exclusive with offset.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "type", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "type__ic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__ie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__iew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__iregex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__isw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__nic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__nie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__niew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__nisw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__regex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "updated_by_request", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "tags": [ + "dcim" + ], + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedCoolingIntakeTemplateList" + } + } + }, + "description": "" + } + } + }, + "post": { + "operationId": "dcim_cooling_intake_templates_create", + "description": "Post a list of cooling intake template objects.", + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/WritableCoolingIntakeTemplateRequest" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/WritableCoolingIntakeTemplateRequest" + } + } + ] + } + }, + "multipart/form-data": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/WritableCoolingIntakeTemplateRequest" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/WritableCoolingIntakeTemplateRequest" + } + } + ] + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoolingIntakeTemplate" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." + } + } + }, + "put": { + "operationId": "dcim_cooling_intake_templates_bulk_update", + "description": "Put a list of cooling intake template objects.", + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BulkCoolingIntakeTemplateRequest" + } + } + }, + "multipart/form-data": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BulkCoolingIntakeTemplateRequest" + } + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingIntakeTemplate" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." + } + } + }, + "patch": { + "operationId": "dcim_cooling_intake_templates_bulk_partial_update", + "description": "Patch a list of cooling intake template objects.", + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PatchedBulkCoolingIntakeTemplateRequest" + } + } + }, + "multipart/form-data": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PatchedBulkCoolingIntakeTemplateRequest" + } + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingIntakeTemplate" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." + } + } + }, + "delete": { + "operationId": "dcim_cooling_intake_templates_bulk_destroy", + "description": "Delete a list of cooling intake template objects.", + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingIntakeTemplateRequest" + } + } + }, + "multipart/form-data": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingIntakeTemplateRequest" + } + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." + } + } + } + }, + "/api/dcim/cooling-intake-templates/{id}/": { + "get": { + "operationId": "dcim_cooling_intake_templates_retrieve", + "description": "Get a cooling intake template object.", + "parameters": [ + { + "in": "query", + "name": "brief", + "schema": { + "type": "boolean" + }, + "description": "Return only brief fields for each object." + }, + { + "in": "query", + "name": "fields", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of fields to include in the response. Example: `fields=id,name`." + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this cooling intake template.", + "required": true + }, + { + "in": "query", + "name": "omit", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of fields to exclude from the response. Example: `omit=description,tags`." + } + ], + "tags": [ + "dcim" + ], + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoolingIntakeTemplate" + } + } + }, + "description": "" + } + } + }, + "put": { + "operationId": "dcim_cooling_intake_templates_update", + "description": "Put a cooling intake template object.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this cooling intake template.", + "required": true + } + ], + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WritableCoolingIntakeTemplateRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/WritableCoolingIntakeTemplateRequest" + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoolingIntakeTemplate" + } + } + }, + "description": "" + } + } + }, + "patch": { + "operationId": "dcim_cooling_intake_templates_partial_update", + "description": "Patch a cooling intake template object.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this cooling intake template.", + "required": true + } + ], + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedWritableCoolingIntakeTemplateRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedWritableCoolingIntakeTemplateRequest" + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoolingIntakeTemplate" + } + } + }, + "description": "" + } + } + }, + "delete": { + "operationId": "dcim_cooling_intake_templates_destroy", + "description": "Delete a cooling intake template object.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this cooling intake template.", + "required": true + } + ], + "tags": [ + "dcim" + ], + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + } + } + } + }, + "/api/dcim/cooling-intakes/": { + "get": { + "operationId": "dcim_cooling_intakes_list", + "description": "Get a list of cooling intake objects.", + "parameters": [ + { + "in": "query", + "name": "brief", + "schema": { + "type": "boolean" + }, + "description": "Return only brief fields for each object." + }, + { + "in": "query", + "name": "cooling_outflow_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Cooling outflow (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_outflow_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Cooling outflow (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__empty", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__gt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__gte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__lt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__lte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created_by_request", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "description", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "description__ic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__ie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__iew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__iregex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__isw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__nic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__nie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__niew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__nisw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__regex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device", + "schema": { + "type": "array", + "items": { + "type": "string", + "nullable": true + } + }, + "description": "Device (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "nullable": true + } + }, + "description": "Device (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Device (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Device (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_role", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Device role (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_role__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Device role (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_role_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Device role (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_role_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Device role (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_status", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "65feb4244cc9110c" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_status__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "device_status__ic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "65feb4244cc9110c" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_status__ie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "65feb4244cc9110c" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_status__iew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "65feb4244cc9110c" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_status__iregex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "65feb4244cc9110c" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_status__isw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "65feb4244cc9110c" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_status__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "65feb4244cc9110c" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_status__nic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "65feb4244cc9110c" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_status__nie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "65feb4244cc9110c" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_status__niew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "65feb4244cc9110c" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_status__nisw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "65feb4244cc9110c" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_status__regex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "65feb4244cc9110c" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_type", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Device type (model)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_type__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Device type (model)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_type_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Device type (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_type_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Device type (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "diameter__gt", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter__gte", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter__lt", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter__lte", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter__n", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "diameter_unit__ic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__ie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__iew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__iregex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__isw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__nic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__nie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__niew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__nisw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__regex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "fields", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of fields to include in the response. Example: `fields=id,name`." + }, + { + "in": "query", + "name": "id", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "id__gt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__gte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__lt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__lte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "label__ic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__ie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__iew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__iregex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__isw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__nic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__nie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__niew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__nisw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__regex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__empty", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__gt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__gte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__lt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__lte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "name": "limit", + "required": false, + "in": "query", + "description": "Number of results to return per page.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "location", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Location (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "location__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Location (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "location_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Location (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "location_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Location (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "max_flow__gt", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow__gte", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow__lt", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow__lte", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow__n", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "max_flow_unit__ic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__ie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__iew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__iregex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__isw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__nic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__nie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__niew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__nisw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "max_flow_unit__regex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "modified_by_request", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "module_id", + "schema": { + "type": "array", + "items": { + "type": "integer", + "nullable": true + } + }, + "description": "Module (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "module_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "nullable": true + } + }, + "description": "Module (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "name__ic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__ie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__iew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__iregex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__isw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__nic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__nie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__niew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__nisw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__regex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "name": "offset", + "required": false, + "in": "query", + "description": "The initial index from which to return the results.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "omit", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of fields to exclude from the response. Example: `omit=description,tags`." + }, + { + "name": "ordering", + "required": false, + "in": "query", + "description": "Which field to use when ordering the results.", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "owner", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Owner (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Owner (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_group", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Owner Group (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_group__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Owner Group (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_group_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Owner Group (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_group_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Owner Group (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_id", + "schema": { + "type": "array", + "items": { + "type": "integer", + "nullable": true + } + }, + "description": "Owner (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "nullable": true + } + }, + "description": "Owner (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + }, + "description": "Search" + }, + { + "in": "query", + "name": "rack", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Rack (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "rack__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Rack (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "rack_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Rack (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "rack_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Rack (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "region", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "region__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "region_id", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "region_id__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Site name (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Site name (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site_group", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site_group__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site_group_id", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site_group_id__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Site (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Site (ID)", + "explode": true, + "style": "form" + }, + { + "name": "start", + "required": false, + "in": "query", + "description": "Cursor-based pagination: return results with pk >= start, ordered by pk. Mutually exclusive with offset.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "tag", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tag__any", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tag__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tag_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tag_id__any", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tag_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tenant", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Tenant (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tenant__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Tenant (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tenant_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Tenant (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tenant_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Tenant (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "description": "Physical connector type", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "type__ic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "description": "Physical connector type", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__ie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "description": "Physical connector type", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__iew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "description": "Physical connector type", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__iregex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "description": "Physical connector type", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__isw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "description": "Physical connector type", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "description": "Physical connector type", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__nic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "description": "Physical connector type", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__nie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "description": "Physical connector type", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__niew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "description": "Physical connector type", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__nisw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "description": "Physical connector type", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__regex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "description": "Physical connector type", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "updated_by_request", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "virtual_chassis", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Virtual Chassis", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "virtual_chassis__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Virtual Chassis", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "virtual_chassis_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Virtual Chassis (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "virtual_chassis_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Virtual Chassis (ID)", + "explode": true, + "style": "form" + } + ], + "tags": [ + "dcim" + ], + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedCoolingIntakeList" + } + } + }, + "description": "" + } + } + }, + "post": { + "operationId": "dcim_cooling_intakes_create", + "description": "Post a list of cooling intake objects.", + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/WritableCoolingIntakeRequest" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/WritableCoolingIntakeRequest" + } + } + ] + } + }, + "multipart/form-data": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/WritableCoolingIntakeRequest" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/WritableCoolingIntakeRequest" + } + } + ] + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoolingIntake" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." + } + } + }, + "put": { + "operationId": "dcim_cooling_intakes_bulk_update", + "description": "Put a list of cooling intake objects.", + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BulkCoolingIntakeRequest" + } + } + }, + "multipart/form-data": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BulkCoolingIntakeRequest" + } + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingIntake" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." + } + } + }, + "patch": { + "operationId": "dcim_cooling_intakes_bulk_partial_update", + "description": "Patch a list of cooling intake objects.", + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PatchedBulkCoolingIntakeRequest" + } + } + }, + "multipart/form-data": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PatchedBulkCoolingIntakeRequest" + } + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingIntake" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." + } + } + }, + "delete": { + "operationId": "dcim_cooling_intakes_bulk_destroy", + "description": "Delete a list of cooling intake objects.", + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingIntakeRequest" + } + } + }, + "multipart/form-data": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingIntakeRequest" + } + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." + } + } + } + }, + "/api/dcim/cooling-intakes/{id}/": { + "get": { + "operationId": "dcim_cooling_intakes_retrieve", + "description": "Get a cooling intake object.", + "parameters": [ + { + "in": "query", + "name": "brief", + "schema": { + "type": "boolean" + }, + "description": "Return only brief fields for each object." + }, + { + "in": "query", + "name": "fields", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of fields to include in the response. Example: `fields=id,name`." + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this cooling intake.", + "required": true + }, + { + "in": "query", + "name": "omit", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of fields to exclude from the response. Example: `omit=description,tags`." + } + ], + "tags": [ + "dcim" + ], + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoolingIntake" + } + } + }, + "description": "" + } + } + }, + "put": { + "operationId": "dcim_cooling_intakes_update", + "description": "Put a cooling intake object.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this cooling intake.", + "required": true + } + ], + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WritableCoolingIntakeRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/WritableCoolingIntakeRequest" + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoolingIntake" + } + } + }, + "description": "" + } + } + }, + "patch": { + "operationId": "dcim_cooling_intakes_partial_update", + "description": "Patch a cooling intake object.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this cooling intake.", + "required": true + } + ], + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedWritableCoolingIntakeRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedWritableCoolingIntakeRequest" + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoolingIntake" + } + } + }, + "description": "" + } + } + }, + "delete": { + "operationId": "dcim_cooling_intakes_destroy", + "description": "Delete a cooling intake object.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this cooling intake.", + "required": true + } + ], + "tags": [ + "dcim" + ], + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + } + } + } + }, + "/api/dcim/cooling-outflow-templates/": { + "get": { + "operationId": "dcim_cooling_outflow_templates_list", + "description": "Get a list of cooling outflow template objects.", + "parameters": [ + { + "in": "query", + "name": "brief", + "schema": { + "type": "boolean" + }, + "description": "Return only brief fields for each object." + }, + { + "in": "query", + "name": "cooling_intake_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Cooling intake (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_intake_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Cooling intake (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__empty", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__gt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__gte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__lt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__lte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created_by_request", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "description", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "description__ic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__ie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__iew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__iregex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__isw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__nic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__nie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__niew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__nisw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__regex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_type_id", + "schema": { + "type": "array", + "items": { + "type": "integer", + "nullable": true + } + }, + "description": "Device type (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_type_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "nullable": true + } + }, + "description": "Device type (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "diameter__gt", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter__gte", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter__lt", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter__lte", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter__n", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "diameter_unit__ic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__ie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__iew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__iregex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__isw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__nic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__nie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__niew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__nisw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__regex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "fields", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of fields to include in the response. Example: `fields=id,name`." + }, + { + "in": "query", + "name": "id", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "id__gt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__gte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__lt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__lte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "label__ic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__ie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__iew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__iregex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__isw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__nic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__nie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__niew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__nisw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__regex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__empty", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__gt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__gte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__lt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__lte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "name": "limit", + "required": false, + "in": "query", + "description": "Number of results to return per page.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "modified_by_request", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "module_type_id", + "schema": { + "type": "array", + "items": { + "type": "integer", + "nullable": true + } + }, + "description": "Module type (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "module_type_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "nullable": true + } + }, + "description": "Module type (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "name__ic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__ie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__iew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__iregex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__isw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__nic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__nie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__niew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__nisw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__regex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "name": "offset", + "required": false, + "in": "query", + "description": "The initial index from which to return the results.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "omit", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of fields to exclude from the response. Example: `omit=description,tags`." + }, + { + "name": "ordering", + "required": false, + "in": "query", + "description": "Which field to use when ordering the results.", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + }, + "description": "Search" + }, + { + "name": "start", + "required": false, + "in": "query", + "description": "Cursor-based pagination: return results with pk >= start, ordered by pk. Mutually exclusive with offset.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "type", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "type__ic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__ie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__iew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__iregex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__isw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__nic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__nie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__niew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__nisw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__regex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "updated_by_request", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "tags": [ + "dcim" + ], + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedCoolingOutflowTemplateList" + } + } + }, + "description": "" + } + } + }, + "post": { + "operationId": "dcim_cooling_outflow_templates_create", + "description": "Post a list of cooling outflow template objects.", + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/WritableCoolingOutflowTemplateRequest" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/WritableCoolingOutflowTemplateRequest" + } + } + ] + } + }, + "multipart/form-data": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/WritableCoolingOutflowTemplateRequest" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/WritableCoolingOutflowTemplateRequest" + } + } + ] + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoolingOutflowTemplate" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." + } + } + }, + "put": { + "operationId": "dcim_cooling_outflow_templates_bulk_update", + "description": "Put a list of cooling outflow template objects.", + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BulkCoolingOutflowTemplateRequest" + } + } + }, + "multipart/form-data": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BulkCoolingOutflowTemplateRequest" + } + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingOutflowTemplate" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." + } + } + }, + "patch": { + "operationId": "dcim_cooling_outflow_templates_bulk_partial_update", + "description": "Patch a list of cooling outflow template objects.", + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PatchedBulkCoolingOutflowTemplateRequest" + } + } + }, + "multipart/form-data": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PatchedBulkCoolingOutflowTemplateRequest" + } + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingOutflowTemplate" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." + } + } + }, + "delete": { + "operationId": "dcim_cooling_outflow_templates_bulk_destroy", + "description": "Delete a list of cooling outflow template objects.", + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingOutflowTemplateRequest" + } + } + }, + "multipart/form-data": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingOutflowTemplateRequest" + } + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." + } + } + } + }, + "/api/dcim/cooling-outflow-templates/{id}/": { + "get": { + "operationId": "dcim_cooling_outflow_templates_retrieve", + "description": "Get a cooling outflow template object.", + "parameters": [ + { + "in": "query", + "name": "brief", + "schema": { + "type": "boolean" + }, + "description": "Return only brief fields for each object." + }, + { + "in": "query", + "name": "fields", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of fields to include in the response. Example: `fields=id,name`." + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this cooling outflow template.", + "required": true + }, + { + "in": "query", + "name": "omit", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of fields to exclude from the response. Example: `omit=description,tags`." + } + ], + "tags": [ + "dcim" + ], + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoolingOutflowTemplate" + } + } + }, + "description": "" + } + } + }, + "put": { + "operationId": "dcim_cooling_outflow_templates_update", + "description": "Put a cooling outflow template object.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this cooling outflow template.", + "required": true + } + ], + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WritableCoolingOutflowTemplateRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/WritableCoolingOutflowTemplateRequest" + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoolingOutflowTemplate" + } + } + }, + "description": "" + } + } + }, + "patch": { + "operationId": "dcim_cooling_outflow_templates_partial_update", + "description": "Patch a cooling outflow template object.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this cooling outflow template.", + "required": true + } + ], + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedWritableCoolingOutflowTemplateRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedWritableCoolingOutflowTemplateRequest" + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoolingOutflowTemplate" + } + } + }, + "description": "" + } + } + }, + "delete": { + "operationId": "dcim_cooling_outflow_templates_destroy", + "description": "Delete a cooling outflow template object.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this cooling outflow template.", + "required": true + } + ], + "tags": [ + "dcim" + ], + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + } + } + } + }, + "/api/dcim/cooling-outflows/": { + "get": { + "operationId": "dcim_cooling_outflows_list", + "description": "Get a list of cooling outflow objects.", + "parameters": [ + { + "in": "query", + "name": "brief", + "schema": { + "type": "boolean" + }, + "description": "Return only brief fields for each object." + }, + { + "in": "query", + "name": "cooling_intake_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Cooling intake (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_intake_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Cooling intake (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__empty", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__gt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__gte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__lt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__lte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created_by_request", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "description", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "description__ic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__ie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__iew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__iregex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__isw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__nic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__nie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__niew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__nisw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__regex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device", + "schema": { + "type": "array", + "items": { + "type": "string", + "nullable": true + } + }, + "description": "Device (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "nullable": true + } + }, + "description": "Device (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Device (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Device (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_role", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Device role (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_role__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Device role (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_role_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Device role (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_role_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Device role (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_status", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "65feb4244cc9110c" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_status__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "device_status__ic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "65feb4244cc9110c" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_status__ie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "65feb4244cc9110c" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_status__iew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "65feb4244cc9110c" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_status__iregex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "65feb4244cc9110c" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_status__isw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "65feb4244cc9110c" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_status__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "65feb4244cc9110c" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_status__nic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "65feb4244cc9110c" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_status__nie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "65feb4244cc9110c" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_status__niew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "65feb4244cc9110c" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_status__nisw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "65feb4244cc9110c" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_status__regex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "65feb4244cc9110c" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_type", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Device type (model)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_type__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Device type (model)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_type_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Device type (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "device_type_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Device type (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "diameter__gt", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter__gte", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter__lt", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter__lte", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter__n", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "diameter_unit__ic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__ie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__iew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__iregex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__isw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__nic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__nie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__niew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__nisw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "diameter_unit__regex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "fields", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of fields to include in the response. Example: `fields=id,name`." + }, + { + "in": "query", + "name": "id", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "id__gt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__gte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__lt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__lte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "label__ic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__ie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__iew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__iregex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__isw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__nic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__nie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__niew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__nisw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "label__regex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__empty", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__gt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__gte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__lt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__lte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "name": "limit", + "required": false, + "in": "query", + "description": "Number of results to return per page.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "location", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Location (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "location__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Location (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "location_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Location (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "location_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Location (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "modified_by_request", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "module_id", + "schema": { + "type": "array", + "items": { + "type": "integer", + "nullable": true + } + }, + "description": "Module (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "module_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "nullable": true + } + }, + "description": "Module (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "name__ic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__ie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__iew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__iregex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__isw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__nic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__nie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__niew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__nisw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__regex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "name": "offset", + "required": false, + "in": "query", + "description": "The initial index from which to return the results.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "omit", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of fields to exclude from the response. Example: `omit=description,tags`." + }, + { + "name": "ordering", + "required": false, + "in": "query", + "description": "Which field to use when ordering the results.", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "owner", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Owner (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Owner (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_group", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Owner Group (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_group__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Owner Group (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_group_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Owner Group (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_group_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Owner Group (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_id", + "schema": { + "type": "array", + "items": { + "type": "integer", + "nullable": true + } + }, + "description": "Owner (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "nullable": true + } + }, + "description": "Owner (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + }, + "description": "Search" + }, + { + "in": "query", + "name": "rack", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Rack (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "rack__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Rack (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "rack_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Rack (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "rack_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Rack (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "region", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "region__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "region_id", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "region_id__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Site name (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Site name (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site_group", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site_group__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site_group_id", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site_group_id__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Site (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Site (ID)", + "explode": true, + "style": "form" + }, + { + "name": "start", + "required": false, + "in": "query", + "description": "Cursor-based pagination: return results with pk >= start, ordered by pk. Mutually exclusive with offset.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "tag", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tag__any", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tag__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tag_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tag_id__any", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tag_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tenant", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Tenant (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tenant__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Tenant (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tenant_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Tenant (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tenant_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Tenant (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "description": "Physical connector type", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "type__ic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "description": "Physical connector type", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__ie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "description": "Physical connector type", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__iew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "description": "Physical connector type", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__iregex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "description": "Physical connector type", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__isw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "description": "Physical connector type", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "description": "Physical connector type", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__nic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "description": "Physical connector type", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__nie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "description": "Physical connector type", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__niew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "description": "Physical connector type", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__nisw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "description": "Physical connector type", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__regex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + } + }, + "description": "Physical connector type", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "updated_by_request", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "virtual_chassis", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Virtual Chassis", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "virtual_chassis__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Virtual Chassis", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "virtual_chassis_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Virtual Chassis (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "virtual_chassis_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Virtual Chassis (ID)", + "explode": true, + "style": "form" + } + ], + "tags": [ + "dcim" + ], + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedCoolingOutflowList" + } + } + }, + "description": "" + } + } + }, + "post": { + "operationId": "dcim_cooling_outflows_create", + "description": "Post a list of cooling outflow objects.", + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/WritableCoolingOutflowRequest" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/WritableCoolingOutflowRequest" + } + } + ] + } + }, + "multipart/form-data": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/WritableCoolingOutflowRequest" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/WritableCoolingOutflowRequest" + } + } + ] + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoolingOutflow" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." + } + } + }, + "put": { + "operationId": "dcim_cooling_outflows_bulk_update", + "description": "Put a list of cooling outflow objects.", + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BulkCoolingOutflowRequest" + } + } + }, + "multipart/form-data": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BulkCoolingOutflowRequest" + } + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingOutflow" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." + } + } + }, + "patch": { + "operationId": "dcim_cooling_outflows_bulk_partial_update", + "description": "Patch a list of cooling outflow objects.", + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PatchedBulkCoolingOutflowRequest" + } + } + }, + "multipart/form-data": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PatchedBulkCoolingOutflowRequest" + } + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingOutflow" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." + } + } + }, + "delete": { + "operationId": "dcim_cooling_outflows_bulk_destroy", + "description": "Delete a list of cooling outflow objects.", + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingOutflowRequest" + } + } + }, + "multipart/form-data": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingOutflowRequest" + } + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." + } + } + } + }, + "/api/dcim/cooling-outflows/{id}/": { + "get": { + "operationId": "dcim_cooling_outflows_retrieve", + "description": "Get a cooling outflow object.", + "parameters": [ + { + "in": "query", + "name": "brief", + "schema": { + "type": "boolean" + }, + "description": "Return only brief fields for each object." + }, + { + "in": "query", + "name": "fields", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of fields to include in the response. Example: `fields=id,name`." + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this cooling outflow.", + "required": true + }, + { + "in": "query", + "name": "omit", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of fields to exclude from the response. Example: `omit=description,tags`." + } + ], + "tags": [ + "dcim" + ], + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoolingOutflow" + } + } + }, + "description": "" + } + } + }, + "put": { + "operationId": "dcim_cooling_outflows_update", + "description": "Put a cooling outflow object.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this cooling outflow.", + "required": true + } + ], + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WritableCoolingOutflowRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/WritableCoolingOutflowRequest" + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoolingOutflow" + } + } + }, + "description": "" + } + } + }, + "patch": { + "operationId": "dcim_cooling_outflows_partial_update", + "description": "Patch a cooling outflow object.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this cooling outflow.", + "required": true + } + ], + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedWritableCoolingOutflowRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedWritableCoolingOutflowRequest" + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoolingOutflow" + } + } + }, + "description": "" + } + } + }, + "delete": { + "operationId": "dcim_cooling_outflows_destroy", + "description": "Delete a cooling outflow object.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this cooling outflow.", + "required": true + } + ], + "tags": [ + "dcim" + ], + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + } + } + } + }, + "/api/dcim/cooling-sources/": { + "get": { + "operationId": "dcim_cooling_sources_list", + "description": "Get a list of cooling source objects.", + "parameters": [ + { + "in": "query", + "name": "brief", + "schema": { + "type": "boolean" + }, + "description": "Return only brief fields for each object." + }, + { + "in": "query", + "name": "contact", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Contact", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "contact__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Contact", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "contact_group", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "contact_group__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "contact_role", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Contact Role", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "contact_role__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Contact Role", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capacity", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capacity__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "cooling_capacity__gt", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capacity__gte", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capacity__lt", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capacity__lte", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capacity__n", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__empty", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__gt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__gte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__lt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__lte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created_by_request", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "description", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "description__ic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__ie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__iew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__iregex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__isw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__nic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__nie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__niew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__nisw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__regex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "fields", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of fields to include in the response. Example: `fields=id,name`." + }, + { + "in": "query", + "name": "fluid_type", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "b558e2a3f7349765", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "fluid_type__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "fluid_type__ic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "b558e2a3f7349765", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "fluid_type__ie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "b558e2a3f7349765", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "fluid_type__iew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "b558e2a3f7349765", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "fluid_type__iregex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "b558e2a3f7349765", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "fluid_type__isw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "b558e2a3f7349765", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "fluid_type__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "b558e2a3f7349765", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "fluid_type__nic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "b558e2a3f7349765", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "fluid_type__nie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "b558e2a3f7349765", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "fluid_type__niew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "b558e2a3f7349765", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "fluid_type__nisw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "b558e2a3f7349765", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "fluid_type__regex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "b558e2a3f7349765", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "id__gt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__gte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__lt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__lte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__empty", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__gt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__gte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__lt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__lte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "name": "limit", + "required": false, + "in": "query", + "description": "Number of results to return per page.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "location", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "location__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "location_id", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "location_id__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "modified_by_request", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "name", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "name__ic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__ie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__iew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__iregex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__isw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__nic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__nie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__niew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__nisw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__regex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "name": "offset", + "required": false, + "in": "query", + "description": "The initial index from which to return the results.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "omit", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of fields to exclude from the response. Example: `omit=description,tags`." + }, + { + "name": "ordering", + "required": false, + "in": "query", + "description": "Which field to use when ordering the results.", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "owner", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Owner (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Owner (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_group", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Owner Group (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_group__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Owner Group (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_group_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Owner Group (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_group_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Owner Group (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_id", + "schema": { + "type": "array", + "items": { + "type": "integer", + "nullable": true + } + }, + "description": "Owner (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "nullable": true + } + }, + "description": "Owner (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + }, + "description": "Search" + }, + { + "in": "query", + "name": "region", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "region__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "region_id", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "region_id__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Site name (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Site name (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site_group", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site_group__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site_group_id", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site_group_id__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Site (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "site_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Site (ID)", + "explode": true, + "style": "form" + }, + { + "name": "start", + "required": false, + "in": "query", + "description": "Cursor-based pagination: return results with pk >= start, ordered by pk. Mutually exclusive with offset.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ec530572dc778583" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "status__ic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ec530572dc778583" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__ie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ec530572dc778583" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__iew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ec530572dc778583" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__iregex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ec530572dc778583" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__isw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ec530572dc778583" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ec530572dc778583" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__nic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ec530572dc778583" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__nie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ec530572dc778583" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__niew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ec530572dc778583" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__nisw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ec530572dc778583" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__regex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ec530572dc778583" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tag", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tag__any", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tag__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tag_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tag_id__any", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tag_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "f225f830b0d77ac5" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "type__ic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "f225f830b0d77ac5" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__ie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "f225f830b0d77ac5" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__iew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "f225f830b0d77ac5" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__iregex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "f225f830b0d77ac5" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__isw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "f225f830b0d77ac5" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "f225f830b0d77ac5" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__nic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "f225f830b0d77ac5" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__nie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "f225f830b0d77ac5" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__niew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "f225f830b0d77ac5" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__nisw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "f225f830b0d77ac5" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "type__regex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "f225f830b0d77ac5" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "updated_by_request", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "tags": [ + "dcim" + ], + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedCoolingSourceList" + } + } + }, + "description": "" + } + } + }, + "post": { + "operationId": "dcim_cooling_sources_create", + "description": "Post a list of cooling source objects.", + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/WritableCoolingSourceRequest" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/WritableCoolingSourceRequest" + } + } + ] + } + }, + "multipart/form-data": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/WritableCoolingSourceRequest" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/WritableCoolingSourceRequest" + } + } + ] + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoolingSource" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." + } + } + }, + "put": { + "operationId": "dcim_cooling_sources_bulk_update", + "description": "Put a list of cooling source objects.", + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BulkCoolingSourceRequest" + } + } + }, + "multipart/form-data": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BulkCoolingSourceRequest" + } + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingSource" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." + } + } + }, + "patch": { + "operationId": "dcim_cooling_sources_bulk_partial_update", + "description": "Patch a list of cooling source objects.", + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PatchedBulkCoolingSourceRequest" + } + } + }, + "multipart/form-data": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PatchedBulkCoolingSourceRequest" + } + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingSource" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." + } + } + }, + "delete": { + "operationId": "dcim_cooling_sources_bulk_destroy", + "description": "Delete a list of cooling source objects.", + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingSourceRequest" + } + } + }, + "multipart/form-data": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingSourceRequest" + } + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." + } + } + } + }, + "/api/dcim/cooling-sources/{id}/": { + "get": { + "operationId": "dcim_cooling_sources_retrieve", + "description": "Get a cooling source object.", + "parameters": [ + { + "in": "query", + "name": "brief", + "schema": { + "type": "boolean" + }, + "description": "Return only brief fields for each object." + }, + { + "in": "query", + "name": "fields", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of fields to include in the response. Example: `fields=id,name`." + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this cooling source.", + "required": true + }, + { + "in": "query", + "name": "omit", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of fields to exclude from the response. Example: `omit=description,tags`." + } + ], + "tags": [ + "dcim" + ], + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoolingSource" + } + } + }, + "description": "" + } + } + }, + "put": { + "operationId": "dcim_cooling_sources_update", + "description": "Put a cooling source object.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this cooling source.", + "required": true + } + ], + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WritableCoolingSourceRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/WritableCoolingSourceRequest" + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoolingSource" + } + } + }, + "description": "" + } + } + }, + "patch": { + "operationId": "dcim_cooling_sources_partial_update", + "description": "Patch a cooling source object.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this cooling source.", + "required": true + } + ], + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedWritableCoolingSourceRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedWritableCoolingSourceRequest" + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoolingSource" + } + } + }, + "description": "" + } + } + }, + "delete": { + "operationId": "dcim_cooling_sources_destroy", + "description": "Delete a cooling source object.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this cooling source.", + "required": true + } + ], + "tags": [ + "dcim" + ], + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + } + } + } + }, "/api/dcim/device-bay-templates/": { "get": { "operationId": "dcim_device_bay_templates_list", @@ -39591,6 +55420,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -39642,6 +55499,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -39693,6 +55570,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -39734,6 +55631,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -41757,6 +57684,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -41808,6 +57763,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -41859,6 +57834,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -41900,6 +57895,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -43471,6 +59496,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -43522,6 +59575,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -43573,6 +59646,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -43614,6 +59707,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -44190,6 +60313,337 @@ }, "description": "Has console server ports" }, + { + "in": "query", + "name": "cooling_intake_template_count", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_intake_template_count__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "cooling_intake_template_count__gt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_intake_template_count__gte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_intake_template_count__lt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_intake_template_count__lte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_intake_template_count__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method", + "schema": { + "type": "string", + "x-spec-enum-id": "fff5375d415a4fdc", + "nullable": true, + "enum": [ + "air", + "hybrid", + "immersion", + "liquid", + "null" + ] + }, + "description": "* `air` - Air\n* `liquid` - Liquid\n* `hybrid` - Hybrid\n* `immersion` - Immersion" + }, + { + "in": "query", + "name": "cooling_method__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "cooling_method__ic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method__ie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method__iew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method__iregex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method__isw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method__n", + "schema": { + "type": "string", + "x-spec-enum-id": "fff5375d415a4fdc", + "nullable": true, + "enum": [ + "air", + "hybrid", + "immersion", + "liquid", + "null" + ] + }, + "description": "* `air` - Air\n* `liquid` - Liquid\n* `hybrid` - Hybrid\n* `immersion` - Immersion" + }, + { + "in": "query", + "name": "cooling_method__nic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method__nie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method__niew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method__nisw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method__regex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_outflow_template_count", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_outflow_template_count__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "cooling_outflow_template_count__gt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_outflow_template_count__gte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_outflow_template_count__lt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_outflow_template_count__lte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_outflow_template_count__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, { "in": "query", "name": "created", @@ -44666,6 +61120,91 @@ "explode": true, "style": "form" }, + { + "in": "query", + "name": "end_of_life", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "end_of_life__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "end_of_life__gt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "end_of_life__gte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "end_of_life__lt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "end_of_life__lte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "end_of_life__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date" + } + }, + "explode": true, + "style": "form" + }, { "in": "query", "name": "exclude_from_utilization", @@ -46848,6 +63387,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -46899,6 +63466,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -46950,6 +63537,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -46991,6 +63598,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -47902,6 +64539,337 @@ "explode": true, "style": "form" }, + { + "in": "query", + "name": "cooling_intake_count", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_intake_count__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "cooling_intake_count__gt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_intake_count__gte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_intake_count__lt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_intake_count__lte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_intake_count__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method", + "schema": { + "type": "string", + "x-spec-enum-id": "fff5375d415a4fdc", + "nullable": true, + "enum": [ + "air", + "hybrid", + "immersion", + "liquid", + "null" + ] + }, + "description": "* `air` - Air\n* `liquid` - Liquid\n* `hybrid` - Hybrid\n* `immersion` - Immersion" + }, + { + "in": "query", + "name": "cooling_method__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "cooling_method__ic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method__ie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method__iew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method__iregex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method__isw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method__n", + "schema": { + "type": "string", + "x-spec-enum-id": "fff5375d415a4fdc", + "nullable": true, + "enum": [ + "air", + "hybrid", + "immersion", + "liquid", + "null" + ] + }, + "description": "* `air` - Air\n* `liquid` - Liquid\n* `hybrid` - Hybrid\n* `immersion` - Immersion" + }, + { + "in": "query", + "name": "cooling_method__nic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method__nie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method__niew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method__nisw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method__regex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_outflow_count", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_outflow_count__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "cooling_outflow_count__gt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_outflow_count__gte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_outflow_count__lt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_outflow_count__lte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_outflow_count__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, { "in": "query", "name": "created", @@ -51326,7 +68294,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedDeviceWithConfigContextList" + "$ref": "#/components/schemas/PaginatedDeviceList" } } }, @@ -51346,12 +68314,12 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/WritableDeviceWithConfigContextRequest" + "$ref": "#/components/schemas/WritableDeviceRequest" }, { "type": "array", "items": { - "$ref": "#/components/schemas/WritableDeviceWithConfigContextRequest" + "$ref": "#/components/schemas/WritableDeviceRequest" } } ] @@ -51361,12 +68329,12 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/WritableDeviceWithConfigContextRequest" + "$ref": "#/components/schemas/WritableDeviceRequest" }, { "type": "array", "items": { - "$ref": "#/components/schemas/WritableDeviceWithConfigContextRequest" + "$ref": "#/components/schemas/WritableDeviceRequest" } } ] @@ -51388,11 +68356,39 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceWithConfigContext" + "$ref": "#/components/schemas/Device" } } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -51408,7 +68404,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/BulkDeviceWithConfigContextRequest" + "$ref": "#/components/schemas/BulkDeviceRequest" } } }, @@ -51416,7 +68412,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/BulkDeviceWithConfigContextRequest" + "$ref": "#/components/schemas/BulkDeviceRequest" } } } @@ -51438,12 +68434,32 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/DeviceWithConfigContext" + "$ref": "#/components/schemas/Device" } } } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -51459,7 +68475,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/PatchedBulkDeviceWithConfigContextRequest" + "$ref": "#/components/schemas/PatchedBulkDeviceRequest" } } }, @@ -51467,7 +68483,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/PatchedBulkDeviceWithConfigContextRequest" + "$ref": "#/components/schemas/PatchedBulkDeviceRequest" } } } @@ -51489,12 +68505,32 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/DeviceWithConfigContext" + "$ref": "#/components/schemas/Device" } } } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -51510,7 +68546,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/DeviceWithConfigContextRequest" + "$ref": "#/components/schemas/DeviceRequest" } } }, @@ -51518,7 +68554,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/DeviceWithConfigContextRequest" + "$ref": "#/components/schemas/DeviceRequest" } } } @@ -51536,6 +68572,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -51595,7 +68661,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceWithConfigContext" + "$ref": "#/components/schemas/Device" } } }, @@ -51624,12 +68690,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WritableDeviceWithConfigContextRequest" + "$ref": "#/components/schemas/WritableDeviceRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/WritableDeviceWithConfigContextRequest" + "$ref": "#/components/schemas/WritableDeviceRequest" } } }, @@ -51648,7 +68714,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceWithConfigContext" + "$ref": "#/components/schemas/Device" } } }, @@ -51677,12 +68743,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedWritableDeviceWithConfigContextRequest" + "$ref": "#/components/schemas/PatchedWritableDeviceRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedWritableDeviceWithConfigContextRequest" + "$ref": "#/components/schemas/PatchedWritableDeviceRequest" } } } @@ -51700,7 +68766,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceWithConfigContext" + "$ref": "#/components/schemas/Device" } } }, @@ -53226,6 +70292,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -53277,6 +70371,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -53328,6 +70442,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -53369,6 +70503,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -56073,6 +73237,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -56124,6 +73316,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -56175,6 +73387,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -56216,6 +73448,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -56521,6 +73783,176 @@ }, "description": "Return only brief fields for each object." }, + { + "in": "query", + "name": "channel_id", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "channel_id__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "channel_id__gt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "channel_id__gte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "channel_id__lt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "channel_id__lte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "channel_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "channels", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "channels__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "channels__gt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "channels__gte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "channels__lt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "channels__lte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "channels__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, { "in": "query", "name": "created", @@ -57370,6 +74802,30 @@ "type": "string" } }, + { + "in": "query", + "name": "parent_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "parent_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "explode": true, + "style": "form" + }, { "in": "query", "name": "poe_mode", @@ -57931,7 +75387,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b0c97040e5abdff1" + "x-spec-enum-id": "19cc901fcea417ad" } }, "explode": true, @@ -57951,7 +75407,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b0c97040e5abdff1" + "x-spec-enum-id": "19cc901fcea417ad" } }, "explode": true, @@ -57964,7 +75420,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b0c97040e5abdff1" + "x-spec-enum-id": "19cc901fcea417ad" } }, "explode": true, @@ -57977,7 +75433,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b0c97040e5abdff1" + "x-spec-enum-id": "19cc901fcea417ad" } }, "explode": true, @@ -57990,7 +75446,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b0c97040e5abdff1" + "x-spec-enum-id": "19cc901fcea417ad" } }, "explode": true, @@ -58003,7 +75459,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b0c97040e5abdff1" + "x-spec-enum-id": "19cc901fcea417ad" } }, "explode": true, @@ -58016,7 +75472,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b0c97040e5abdff1" + "x-spec-enum-id": "19cc901fcea417ad" } }, "explode": true, @@ -58029,7 +75485,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b0c97040e5abdff1" + "x-spec-enum-id": "19cc901fcea417ad" } }, "explode": true, @@ -58042,7 +75498,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b0c97040e5abdff1" + "x-spec-enum-id": "19cc901fcea417ad" } }, "explode": true, @@ -58055,7 +75511,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b0c97040e5abdff1" + "x-spec-enum-id": "19cc901fcea417ad" } }, "explode": true, @@ -58068,7 +75524,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b0c97040e5abdff1" + "x-spec-enum-id": "19cc901fcea417ad" } }, "explode": true, @@ -58081,7 +75537,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b0c97040e5abdff1" + "x-spec-enum-id": "19cc901fcea417ad" } }, "explode": true, @@ -58179,6 +75635,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -58230,6 +75714,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -58281,6 +75785,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -58322,6 +75846,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -58842,6 +76396,176 @@ "type": "boolean" } }, + { + "in": "query", + "name": "channel_id", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "channel_id__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "channel_id__gt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "channel_id__gte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "channel_id__lt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "channel_id__lte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "channel_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "channels", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "channels__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "channels__gt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "channels__gte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "channels__lt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "channels__lte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "channels__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, { "in": "query", "name": "connected", @@ -62345,7 +80069,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b0c97040e5abdff1" + "x-spec-enum-id": "19cc901fcea417ad" } }, "explode": true, @@ -62365,7 +80089,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b0c97040e5abdff1" + "x-spec-enum-id": "19cc901fcea417ad" } }, "explode": true, @@ -62378,7 +80102,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b0c97040e5abdff1" + "x-spec-enum-id": "19cc901fcea417ad" } }, "explode": true, @@ -62391,7 +80115,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b0c97040e5abdff1" + "x-spec-enum-id": "19cc901fcea417ad" } }, "explode": true, @@ -62404,7 +80128,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b0c97040e5abdff1" + "x-spec-enum-id": "19cc901fcea417ad" } }, "explode": true, @@ -62417,7 +80141,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b0c97040e5abdff1" + "x-spec-enum-id": "19cc901fcea417ad" } }, "explode": true, @@ -62430,7 +80154,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b0c97040e5abdff1" + "x-spec-enum-id": "19cc901fcea417ad" } }, "explode": true, @@ -62443,7 +80167,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b0c97040e5abdff1" + "x-spec-enum-id": "19cc901fcea417ad" } }, "explode": true, @@ -62456,7 +80180,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b0c97040e5abdff1" + "x-spec-enum-id": "19cc901fcea417ad" } }, "explode": true, @@ -62469,7 +80193,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b0c97040e5abdff1" + "x-spec-enum-id": "19cc901fcea417ad" } }, "explode": true, @@ -62482,7 +80206,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b0c97040e5abdff1" + "x-spec-enum-id": "19cc901fcea417ad" } }, "explode": true, @@ -62495,7 +80219,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b0c97040e5abdff1" + "x-spec-enum-id": "19cc901fcea417ad" } }, "explode": true, @@ -63153,6 +80877,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -63204,6 +80956,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -63255,6 +81027,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -63296,6 +81088,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -64794,6 +82616,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -64845,6 +82695,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -64896,6 +82766,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -64937,6 +82827,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -66470,6 +84390,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -66521,6 +84469,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -66572,6 +84540,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -66613,6 +84601,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -69447,6 +87465,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -69498,6 +87544,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -69549,6 +87615,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -69590,6 +87676,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -71615,6 +89731,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -71666,6 +89810,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -71717,6 +89881,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -71758,6 +89942,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -73162,6 +91376,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -73213,6 +91455,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -73264,6 +91526,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -73305,6 +91587,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -74664,6 +92976,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -74715,6 +93055,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -74766,6 +93126,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -74807,6 +93187,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -75661,6 +94071,58 @@ "format": "uuid" } }, + { + "in": "query", + "name": "module_bay_type", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Module bay type (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "module_bay_type__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Module bay type (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "module_bay_type_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Module bay type (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "module_bay_type_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Module bay type (ID)", + "explode": true, + "style": "form" + }, { "in": "query", "name": "module_type_id", @@ -76126,6 +94588,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -76177,6 +94667,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -76228,6 +94738,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -76269,6 +94799,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -76473,6 +95033,1813 @@ } } }, + "/api/dcim/module-bay-types/": { + "get": { + "operationId": "dcim_module_bay_types_list", + "description": "Get a list of module bay type objects.", + "parameters": [ + { + "in": "query", + "name": "brief", + "schema": { + "type": "boolean" + }, + "description": "Return only brief fields for each object." + }, + { + "in": "query", + "name": "color", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "color__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "color__ic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "color__ie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "color__iew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "color__iregex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "color__isw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "color__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "color__nic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "color__nie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "color__niew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "color__nisw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "color__regex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__empty", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__gt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__gte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__lt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__lte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "created_by_request", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "description", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "description__ic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__ie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__iew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__iregex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__isw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__nic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__nie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__niew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__nisw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__regex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "fields", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of fields to include in the response. Example: `fields=id,name`." + }, + { + "in": "query", + "name": "id", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "id__gt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__gte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__lt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__lte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__empty", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__gt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__gte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__lt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__lte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "last_updated__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + }, + "explode": true, + "style": "form" + }, + { + "name": "limit", + "required": false, + "in": "query", + "description": "Number of results to return per page.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "manufacturer", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Manufacturer (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "manufacturer__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Manufacturer (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "manufacturer_id", + "schema": { + "type": "array", + "items": { + "type": "integer", + "nullable": true + } + }, + "description": "Manufacturer (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "manufacturer_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "nullable": true + } + }, + "description": "Manufacturer (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "modified_by_request", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "module_bay_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Module bay (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "module_bay_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Module bay (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "module_bay_template_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Module bay template (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "module_bay_template_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Module bay template (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "module_type_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Module type (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "module_type_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Module type (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "name__ic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__ie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__iew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__iregex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__isw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__nic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__nie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__niew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__nisw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__regex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "name": "offset", + "required": false, + "in": "query", + "description": "The initial index from which to return the results.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "omit", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of fields to exclude from the response. Example: `omit=description,tags`." + }, + { + "name": "ordering", + "required": false, + "in": "query", + "description": "Which field to use when ordering the results.", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "owner", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Owner (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Owner (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_group", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Owner Group (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_group__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Owner Group (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_group_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Owner Group (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_group_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Owner Group (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_id", + "schema": { + "type": "array", + "items": { + "type": "integer", + "nullable": true + } + }, + "description": "Owner (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "owner_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "nullable": true + } + }, + "description": "Owner (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + }, + "description": "Search" + }, + { + "in": "query", + "name": "slug", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "slug__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "slug__ic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "slug__ie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "slug__iew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "slug__iregex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "slug__isw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "slug__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "slug__nic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "slug__nie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "slug__niew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "slug__nisw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "slug__regex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "name": "start", + "required": false, + "in": "query", + "description": "Cursor-based pagination: return results with pk >= start, ordered by pk. Mutually exclusive with offset.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "tag", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tag__any", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tag__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tag_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tag_id__any", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "tag_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "updated_by_request", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "tags": [ + "dcim" + ], + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedModuleBayTypeList" + } + } + }, + "description": "" + } + } + }, + "post": { + "operationId": "dcim_module_bay_types_create", + "description": "Post a list of module bay type objects.", + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ModuleBayTypeRequest" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModuleBayTypeRequest" + } + } + ] + } + }, + "multipart/form-data": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ModuleBayTypeRequest" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModuleBayTypeRequest" + } + } + ] + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleBayType" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." + } + } + }, + "put": { + "operationId": "dcim_module_bay_types_bulk_update", + "description": "Put a list of module bay type objects.", + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BulkModuleBayTypeRequest" + } + } + }, + "multipart/form-data": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BulkModuleBayTypeRequest" + } + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModuleBayType" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." + } + } + }, + "patch": { + "operationId": "dcim_module_bay_types_bulk_partial_update", + "description": "Patch a list of module bay type objects.", + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PatchedBulkModuleBayTypeRequest" + } + } + }, + "multipart/form-data": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PatchedBulkModuleBayTypeRequest" + } + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModuleBayType" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." + } + } + }, + "delete": { + "operationId": "dcim_module_bay_types_bulk_destroy", + "description": "Delete a list of module bay type objects.", + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModuleBayTypeRequest" + } + } + }, + "multipart/form-data": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModuleBayTypeRequest" + } + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." + } + } + } + }, + "/api/dcim/module-bay-types/{id}/": { + "get": { + "operationId": "dcim_module_bay_types_retrieve", + "description": "Get a module bay type object.", + "parameters": [ + { + "in": "query", + "name": "brief", + "schema": { + "type": "boolean" + }, + "description": "Return only brief fields for each object." + }, + { + "in": "query", + "name": "fields", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of fields to include in the response. Example: `fields=id,name`." + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this module bay type.", + "required": true + }, + { + "in": "query", + "name": "omit", + "schema": { + "type": "string" + }, + "description": "Comma-separated list of fields to exclude from the response. Example: `omit=description,tags`." + } + ], + "tags": [ + "dcim" + ], + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleBayType" + } + } + }, + "description": "" + } + } + }, + "put": { + "operationId": "dcim_module_bay_types_update", + "description": "Put a module bay type object.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this module bay type.", + "required": true + } + ], + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleBayTypeRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ModuleBayTypeRequest" + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleBayType" + } + } + }, + "description": "" + } + } + }, + "patch": { + "operationId": "dcim_module_bay_types_partial_update", + "description": "Patch a module bay type object.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this module bay type.", + "required": true + } + ], + "tags": [ + "dcim" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedModuleBayTypeRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedModuleBayTypeRequest" + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleBayType" + } + } + }, + "description": "" + } + } + }, + "delete": { + "operationId": "dcim_module_bay_types_destroy", + "description": "Delete a module bay type object.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this module bay type.", + "required": true + } + ], + "tags": [ + "dcim" + ], + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + } + } + } + }, "/api/dcim/module-bays/": { "get": { "operationId": "dcim_module_bays_list", @@ -77494,6 +97861,58 @@ "format": "uuid" } }, + { + "in": "query", + "name": "module_bay_type", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Module bay type (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "module_bay_type__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Module bay type (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "module_bay_type_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Module bay type (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "module_bay_type_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Module bay type (ID)", + "explode": true, + "style": "form" + }, { "in": "query", "name": "module_id", @@ -78469,6 +98888,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -78520,6 +98967,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -78571,6 +99038,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -78612,6 +99099,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -79744,6 +100261,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -79795,6 +100340,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -79846,6 +100411,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -79887,6 +100472,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -80455,6 +101070,337 @@ }, "description": "Has console server ports" }, + { + "in": "query", + "name": "cooling_intake_template_count", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_intake_template_count__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "cooling_intake_template_count__gt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_intake_template_count__gte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_intake_template_count__lt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_intake_template_count__lte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_intake_template_count__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method", + "schema": { + "type": "string", + "x-spec-enum-id": "fff5375d415a4fdc", + "nullable": true, + "enum": [ + "air", + "hybrid", + "immersion", + "liquid", + "null" + ] + }, + "description": "* `air` - Air\n* `liquid` - Liquid\n* `hybrid` - Hybrid\n* `immersion` - Immersion" + }, + { + "in": "query", + "name": "cooling_method__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "cooling_method__ic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method__ie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method__iew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method__iregex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method__isw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method__n", + "schema": { + "type": "string", + "x-spec-enum-id": "fff5375d415a4fdc", + "nullable": true, + "enum": [ + "air", + "hybrid", + "immersion", + "liquid", + "null" + ] + }, + "description": "* `air` - Air\n* `liquid` - Liquid\n* `hybrid` - Hybrid\n* `immersion` - Immersion" + }, + { + "in": "query", + "name": "cooling_method__nic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method__nie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method__niew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method__nisw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_method__regex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_outflow_template_count", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_outflow_template_count__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "cooling_outflow_template_count__gt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_outflow_template_count__gte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_outflow_template_count__lt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_outflow_template_count__lte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_outflow_template_count__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, { "in": "query", "name": "created", @@ -80705,6 +101651,91 @@ "explode": true, "style": "form" }, + { + "in": "query", + "name": "end_of_life", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "end_of_life__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "end_of_life__gt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "end_of_life__gte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "end_of_life__lt", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "end_of_life__lte", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "end_of_life__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "date" + } + }, + "explode": true, + "style": "form" + }, { "in": "query", "name": "fields", @@ -81372,6 +102403,58 @@ "explode": true, "style": "form" }, + { + "in": "query", + "name": "module_bay_type", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Module bay type (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "module_bay_type__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Module bay type (slug)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "module_bay_type_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Module bay type (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "module_bay_type_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Module bay type (ID)", + "explode": true, + "style": "form" + }, { "in": "query", "name": "module_bays", @@ -82508,6 +103591,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -82559,6 +103670,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -82610,6 +103741,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -82651,6 +103802,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -84583,6 +105764,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -84634,6 +105843,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -84685,6 +105914,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -84726,6 +105975,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -86198,6 +107477,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -86249,6 +107556,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -86300,6 +107627,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -86341,6 +107688,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -89041,6 +110418,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -89092,6 +110497,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -89143,6 +110568,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -89184,6 +110629,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -90952,6 +112427,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -91003,6 +112506,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -91054,6 +112577,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -91095,6 +112638,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -94097,6 +115670,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -94148,6 +115749,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -94199,6 +115820,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -94240,6 +115881,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -95684,6 +117355,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -95735,6 +117434,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -95786,6 +117505,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -95827,6 +117566,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -97335,6 +119104,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -97386,6 +119183,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -97437,6 +119254,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -97478,6 +119315,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -100121,6 +121988,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -100172,6 +122067,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -100223,6 +122138,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -100264,6 +122199,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -101611,6 +123576,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -101662,6 +123655,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -101713,6 +123726,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -101754,6 +123787,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -103391,6 +125454,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -103442,6 +125533,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -103493,6 +125604,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -103534,6 +125665,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -104968,6 +127129,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -105019,6 +127208,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -105070,6 +127279,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -105111,6 +127340,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -105328,6 +127587,266 @@ }, "description": "Return only brief fields for each object." }, + { + "in": "query", + "name": "cooling_capability", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capability__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "cooling_capability__ic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capability__ie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capability__iew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capability__iregex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capability__isw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capability__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capability__nic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capability__nie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capability__niew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capability__nisw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capability__regex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capacity", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capacity__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "cooling_capacity__gt", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capacity__gte", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capacity__lt", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capacity__lte", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capacity__n", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, { "in": "query", "name": "created", @@ -107867,6 +130386,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -107918,6 +130465,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -107969,6 +130536,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -108010,6 +130597,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -108611,6 +131228,266 @@ "explode": true, "style": "form" }, + { + "in": "query", + "name": "cooling_capability", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capability__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "cooling_capability__ic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capability__ie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capability__iew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capability__iregex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capability__isw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capability__n", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capability__nic", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capability__nie", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capability__niew", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capability__nisw", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capability__regex", + "schema": { + "type": "array", + "items": { + "type": "string", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capacity", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capacity__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "cooling_capacity__gt", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capacity__gte", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capacity__lt", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capacity__lte", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "cooling_capacity__n", + "schema": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "explode": true, + "style": "form" + }, { "in": "query", "name": "created", @@ -111851,6 +134728,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -111902,6 +134807,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -111953,6 +134878,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -111994,6 +134939,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -113762,6 +136737,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -113813,6 +136816,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -113864,6 +136887,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -113905,6 +136948,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -116609,6 +139682,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -116660,6 +139761,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -116711,6 +139832,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -116752,6 +139893,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -118277,6 +141448,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -118328,6 +141527,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -118379,6 +141598,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -118420,6 +141659,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -119881,6 +143150,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -119932,6 +143229,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -119983,6 +143300,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -120024,6 +143361,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -122267,6 +145634,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -122318,6 +145713,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -122369,6 +145784,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -122410,6 +145845,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -124034,6 +147499,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -124085,6 +147578,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -124136,6 +147649,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -124177,6 +147710,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -125849,6 +149412,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -125900,6 +149491,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -125951,6 +149562,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -125992,6 +149623,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -126690,6 +150351,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -126741,6 +150430,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -126792,6 +150501,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -126833,6 +150562,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -128113,6 +151872,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -128164,6 +151951,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -128215,6 +152022,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -128256,6 +152083,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -130289,6 +154146,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -130340,6 +154225,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -130391,6 +154296,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -130432,6 +154357,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -132234,6 +156189,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -132285,6 +156268,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -132336,6 +156339,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -132377,6 +156400,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -133770,6 +157823,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -133821,6 +157902,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -133872,6 +157973,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -133913,6 +158034,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -135312,6 +159463,13 @@ "explode": true, "style": "form" }, + { + "in": "query", + "name": "nulls_first", + "schema": { + "type": "boolean" + } + }, { "in": "query", "name": "object_type", @@ -135769,6 +159927,163 @@ "type": "integer" } }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "x-spec-enum-id": "0b370227c3205532", + "enum": [ + "active", + "deleting", + "null", + "provisioning" + ] + }, + "description": "Operational state of the field\n\n* `active` - Active\n* `provisioning` - Provisioning\n* `deleting` - Deleting" + }, + { + "in": "query", + "name": "status__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "status__ic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__ie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__iew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__iregex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__isw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__n", + "schema": { + "type": "string", + "x-spec-enum-id": "0b370227c3205532", + "enum": [ + "active", + "deleting", + "null", + "provisioning" + ] + }, + "description": "Operational state of the field\n\n* `active` - Active\n* `provisioning` - Provisioning\n* `deleting` - Deleting" + }, + { + "in": "query", + "name": "status__nic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__nie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__niew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__nisw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "status__regex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, { "in": "query", "name": "type", @@ -135776,7 +160091,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "47c52a3d983e924c" + "x-spec-enum-id": "6ec6eff91d34cc44" } }, "description": "The type of data this custom field holds", @@ -135797,7 +160112,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "47c52a3d983e924c" + "x-spec-enum-id": "6ec6eff91d34cc44" } }, "description": "The type of data this custom field holds", @@ -135811,7 +160126,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "47c52a3d983e924c" + "x-spec-enum-id": "6ec6eff91d34cc44" } }, "description": "The type of data this custom field holds", @@ -135825,7 +160140,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "47c52a3d983e924c" + "x-spec-enum-id": "6ec6eff91d34cc44" } }, "description": "The type of data this custom field holds", @@ -135839,7 +160154,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "47c52a3d983e924c" + "x-spec-enum-id": "6ec6eff91d34cc44" } }, "description": "The type of data this custom field holds", @@ -135853,7 +160168,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "47c52a3d983e924c" + "x-spec-enum-id": "6ec6eff91d34cc44" } }, "description": "The type of data this custom field holds", @@ -135867,7 +160182,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "47c52a3d983e924c" + "x-spec-enum-id": "6ec6eff91d34cc44" } }, "description": "The type of data this custom field holds", @@ -135881,7 +160196,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "47c52a3d983e924c" + "x-spec-enum-id": "6ec6eff91d34cc44" } }, "description": "The type of data this custom field holds", @@ -135895,7 +160210,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "47c52a3d983e924c" + "x-spec-enum-id": "6ec6eff91d34cc44" } }, "description": "The type of data this custom field holds", @@ -135909,7 +160224,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "47c52a3d983e924c" + "x-spec-enum-id": "6ec6eff91d34cc44" } }, "description": "The type of data this custom field holds", @@ -135923,7 +160238,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "47c52a3d983e924c" + "x-spec-enum-id": "6ec6eff91d34cc44" } }, "description": "The type of data this custom field holds", @@ -135937,7 +160252,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "47c52a3d983e924c" + "x-spec-enum-id": "6ec6eff91d34cc44" } }, "description": "The type of data this custom field holds", @@ -136763,6 +161078,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -136814,6 +161157,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -136865,6 +161228,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -136906,6 +161289,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -138582,6 +162995,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -138633,6 +163074,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -138684,6 +163145,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -138725,6 +163206,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -139091,6 +163602,14 @@ "operationId": "extras_event_rules_list", "description": "Get a list of event rule objects.", "parameters": [ + { + "in": "query", + "name": "action_is_available", + "schema": { + "type": "boolean" + }, + "description": "Action available" + }, { "in": "query", "name": "action_object_id", @@ -140479,6 +164998,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -140530,6 +165077,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -140581,6 +165148,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -140622,6 +165209,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -142458,6 +167075,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -142509,6 +167154,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -142560,6 +167225,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -142601,6 +167286,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -143988,6 +168703,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -144039,6 +168782,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -144090,6 +168853,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -144131,6 +168914,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -145130,6 +169943,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -145181,6 +170022,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -145232,6 +170093,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -145273,6 +170154,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -145626,6 +170537,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -145677,6 +170616,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -145728,6 +170687,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -145769,6 +170748,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -146122,6 +171131,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -146173,6 +171210,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -146224,6 +171281,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -146265,6 +171342,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -147804,6 +172911,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -147855,6 +172990,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -147906,6 +173061,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -147947,6 +173122,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -148936,6 +174141,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -148987,6 +174220,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -149038,6 +174291,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -149079,6 +174352,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -150392,6 +175695,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -150443,6 +175774,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -150494,6 +175845,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -150535,6 +175906,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -152473,6 +177874,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -152524,6 +177953,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -152575,6 +178024,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -152616,6 +178085,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -154291,6 +179790,91 @@ "explode": true, "style": "form" }, + { + "in": "query", + "name": "timeout", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "timeout__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "timeout__gt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "timeout__gte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "timeout__lt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "timeout__lte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "timeout__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, { "in": "query", "name": "updated_by_request", @@ -154383,6 +179967,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -154434,6 +180046,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -154485,6 +180117,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -154526,6 +180178,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -155837,6 +181519,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -155888,6 +181598,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -155939,6 +181669,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -155980,6 +181730,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -157574,6 +183354,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -157625,6 +183433,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -157676,6 +183504,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -157717,6 +183565,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -159270,6 +185148,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -159321,6 +185227,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -159372,6 +185298,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -159413,6 +185359,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -160327,6 +186303,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -160378,6 +186382,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -160429,6 +186453,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -160470,6 +186514,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -162200,6 +188274,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -162251,6 +188353,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -162302,6 +188424,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -162343,6 +188485,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -164502,6 +190674,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -164553,6 +190753,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -164604,6 +190824,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -164645,6 +190885,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -166241,6 +192511,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -166292,6 +192590,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -166343,6 +192661,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -166384,6 +192722,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -168678,6 +195046,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -168729,6 +195125,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -168780,6 +195196,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -168821,6 +195257,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -170365,6 +196831,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -170416,6 +196910,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -170467,6 +196981,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -170508,6 +197042,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -171876,6 +198440,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -171927,6 +198519,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -171978,6 +198590,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -172019,6 +198651,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -173485,6 +200147,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -173536,6 +200226,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -173587,6 +200297,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -173628,6 +200358,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -174583,124 +201343,107 @@ "in": "query", "name": "port", "schema": { - "type": "number" - } - }, - { - "in": "query", - "name": "port__empty", - "schema": { - "type": "number" - } + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" }, { "in": "query", "name": "port__gt", "schema": { - "type": "number" - } + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" }, { "in": "query", "name": "port__gte", "schema": { - "type": "number" - } + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" }, { "in": "query", "name": "port__lt", "schema": { - "type": "number" - } + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" }, { "in": "query", "name": "port__lte", "schema": { - "type": "number" - } + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" }, { "in": "query", "name": "port__n", "schema": { - "type": "number" - } + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "port_mappings", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "port_mappings__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" }, { "in": "query", "name": "protocol", - "schema": { - "type": "string", - "x-spec-enum-id": "e4b15bec749a2a32", - "enum": [ - "null", - "sctp", - "tcp", - "udp" - ] - }, - "description": "* `tcp` - TCP\n* `udp` - UDP\n* `sctp` - SCTP" - }, - { - "in": "query", - "name": "protocol__empty", - "schema": { - "type": "boolean" - } - }, - { - "in": "query", - "name": "protocol__ic", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "protocol__ie", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "protocol__iew", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "protocol__iregex", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "protocol__isw", "schema": { "type": "array", "items": { @@ -174713,69 +201456,6 @@ { "in": "query", "name": "protocol__n", - "schema": { - "type": "string", - "x-spec-enum-id": "e4b15bec749a2a32", - "enum": [ - "null", - "sctp", - "tcp", - "udp" - ] - }, - "description": "* `tcp` - TCP\n* `udp` - UDP\n* `sctp` - SCTP" - }, - { - "in": "query", - "name": "protocol__nic", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "protocol__nie", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "protocol__niew", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "protocol__nisw", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "protocol__regex", "schema": { "type": "array", "items": { @@ -174966,6 +201646,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -175017,6 +201725,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -175068,6 +201796,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -175109,6 +201857,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -176351,124 +203129,107 @@ "in": "query", "name": "port", "schema": { - "type": "number" - } - }, - { - "in": "query", - "name": "port__empty", - "schema": { - "type": "number" - } + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" }, { "in": "query", "name": "port__gt", "schema": { - "type": "number" - } + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" }, { "in": "query", "name": "port__gte", "schema": { - "type": "number" - } + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" }, { "in": "query", "name": "port__lt", "schema": { - "type": "number" - } + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" }, { "in": "query", "name": "port__lte", "schema": { - "type": "number" - } + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" }, { "in": "query", "name": "port__n", "schema": { - "type": "number" - } + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "port_mappings", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "port_mappings__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" }, { "in": "query", "name": "protocol", - "schema": { - "type": "string", - "x-spec-enum-id": "e4b15bec749a2a32", - "enum": [ - "null", - "sctp", - "tcp", - "udp" - ] - }, - "description": "* `tcp` - TCP\n* `udp` - UDP\n* `sctp` - SCTP" - }, - { - "in": "query", - "name": "protocol__empty", - "schema": { - "type": "boolean" - } - }, - { - "in": "query", - "name": "protocol__ic", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "protocol__ie", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "protocol__iew", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "protocol__iregex", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "protocol__isw", "schema": { "type": "array", "items": { @@ -176481,69 +203242,6 @@ { "in": "query", "name": "protocol__n", - "schema": { - "type": "string", - "x-spec-enum-id": "e4b15bec749a2a32", - "enum": [ - "null", - "sctp", - "tcp", - "udp" - ] - }, - "description": "* `tcp` - TCP\n* `udp` - UDP\n* `sctp` - SCTP" - }, - { - "in": "query", - "name": "protocol__nic", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "protocol__nie", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "protocol__niew", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "protocol__nisw", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "protocol__regex", "schema": { "type": "array", "items": { @@ -176759,6 +203457,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -176810,6 +203536,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -176861,6 +203607,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -176902,6 +203668,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -178592,6 +205388,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -178643,6 +205467,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -178694,6 +205538,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -178735,6 +205599,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -179994,6 +206888,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -180045,6 +206967,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -180096,6 +207038,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -180137,6 +207099,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -181234,6 +208226,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -181285,6 +208305,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -181336,6 +208376,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -181377,6 +208437,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -183549,6 +210639,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -183600,6 +210718,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -183651,6 +210789,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -183692,6 +210850,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -185188,6 +212376,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -185239,6 +212455,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -185290,6 +212526,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -185331,6 +212587,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -186633,6 +213919,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -186684,6 +213998,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -186735,6 +214069,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -186776,6 +214130,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -188187,6 +215571,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -188238,6 +215650,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -188289,6 +215721,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -188330,6 +215782,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -189613,6 +217095,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -189664,6 +217174,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -189715,6 +217245,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -189756,6 +217306,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -191691,6 +219271,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -191742,6 +219350,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -191793,6 +219421,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -191834,6 +219482,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -193219,6 +220897,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -193270,6 +220976,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -193321,6 +221047,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -193362,6 +221108,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -194769,6 +222545,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -194820,6 +222624,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -194871,6 +222695,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -194912,6 +222756,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -195846,6 +223720,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -195897,6 +223799,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -195948,6 +223870,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -195989,6 +223931,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -196737,6 +224709,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -196788,6 +224788,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -196839,6 +224859,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -196880,6 +224920,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -197786,6 +225856,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -197837,6 +225935,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -197888,6 +226006,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -197929,6 +226067,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -199008,6 +227176,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -199059,6 +227255,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -199110,6 +227326,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -199151,6 +227387,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -200280,6 +228546,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -200331,6 +228625,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -200382,6 +228696,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -200423,6 +228757,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -201868,6 +230232,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -201919,6 +230311,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -201970,6 +230382,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -202011,6 +230443,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -203370,6 +231832,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -203421,6 +231911,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -203472,6 +231982,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -203513,6 +232043,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -204796,6 +233356,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -204847,6 +233435,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -204898,6 +233506,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -204939,6 +233567,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -206823,6 +235481,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -206874,6 +235560,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -206925,6 +235631,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -206966,6 +235692,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -208914,6 +237670,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -208965,6 +237749,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -209016,6 +237820,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -209057,6 +237881,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -210326,6 +239180,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -210377,6 +239259,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -210428,6 +239330,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -210469,6 +239391,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -212055,6 +241007,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -212106,6 +241086,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -212157,6 +241157,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -212198,6 +241218,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -215151,7 +244201,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedVirtualMachineWithConfigContextList" + "$ref": "#/components/schemas/PaginatedVirtualMachineList" } } }, @@ -215171,12 +244221,12 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/WritableVirtualMachineWithConfigContextRequest" + "$ref": "#/components/schemas/WritableVirtualMachineRequest" }, { "type": "array", "items": { - "$ref": "#/components/schemas/WritableVirtualMachineWithConfigContextRequest" + "$ref": "#/components/schemas/WritableVirtualMachineRequest" } } ] @@ -215186,12 +244236,12 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/WritableVirtualMachineWithConfigContextRequest" + "$ref": "#/components/schemas/WritableVirtualMachineRequest" }, { "type": "array", "items": { - "$ref": "#/components/schemas/WritableVirtualMachineWithConfigContextRequest" + "$ref": "#/components/schemas/WritableVirtualMachineRequest" } } ] @@ -215213,11 +244263,39 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VirtualMachineWithConfigContext" + "$ref": "#/components/schemas/VirtualMachine" } } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -215233,7 +244311,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/BulkVirtualMachineWithConfigContextRequest" + "$ref": "#/components/schemas/BulkVirtualMachineRequest" } } }, @@ -215241,7 +244319,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/BulkVirtualMachineWithConfigContextRequest" + "$ref": "#/components/schemas/BulkVirtualMachineRequest" } } } @@ -215263,12 +244341,32 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/VirtualMachineWithConfigContext" + "$ref": "#/components/schemas/VirtualMachine" } } } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -215284,7 +244382,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/PatchedBulkVirtualMachineWithConfigContextRequest" + "$ref": "#/components/schemas/PatchedBulkVirtualMachineRequest" } } }, @@ -215292,7 +244390,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/PatchedBulkVirtualMachineWithConfigContextRequest" + "$ref": "#/components/schemas/PatchedBulkVirtualMachineRequest" } } } @@ -215314,12 +244412,32 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/VirtualMachineWithConfigContext" + "$ref": "#/components/schemas/VirtualMachine" } } } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -215335,7 +244453,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/VirtualMachineWithConfigContextRequest" + "$ref": "#/components/schemas/VirtualMachineRequest" } } }, @@ -215343,7 +244461,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/VirtualMachineWithConfigContextRequest" + "$ref": "#/components/schemas/VirtualMachineRequest" } } } @@ -215361,6 +244479,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -215420,7 +244568,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VirtualMachineWithConfigContext" + "$ref": "#/components/schemas/VirtualMachine" } } }, @@ -215449,12 +244597,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WritableVirtualMachineWithConfigContextRequest" + "$ref": "#/components/schemas/WritableVirtualMachineRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/WritableVirtualMachineWithConfigContextRequest" + "$ref": "#/components/schemas/WritableVirtualMachineRequest" } } }, @@ -215473,7 +244621,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VirtualMachineWithConfigContext" + "$ref": "#/components/schemas/VirtualMachine" } } }, @@ -215502,12 +244650,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchedWritableVirtualMachineWithConfigContextRequest" + "$ref": "#/components/schemas/PatchedWritableVirtualMachineRequest" } }, "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PatchedWritableVirtualMachineWithConfigContextRequest" + "$ref": "#/components/schemas/PatchedWritableVirtualMachineRequest" } } } @@ -215525,7 +244673,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VirtualMachineWithConfigContext" + "$ref": "#/components/schemas/VirtualMachine" } } }, @@ -217060,6 +246208,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -217111,6 +246287,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -217162,6 +246358,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -217203,6 +246419,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -219141,6 +248387,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -219192,6 +248466,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -219243,6 +248537,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -219284,6 +248598,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -220644,6 +249988,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -220695,6 +250067,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -220746,6 +250138,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -220787,6 +250199,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -222186,6 +251628,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -222237,6 +251707,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -222288,6 +251778,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -222329,6 +251839,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -224057,6 +253597,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -224108,6 +253676,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -224159,6 +253747,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -224200,6 +253808,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -225477,6 +255115,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -225528,6 +255194,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -225579,6 +255265,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -225620,6 +255326,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -227596,6 +257332,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -227647,6 +257411,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -227698,6 +257482,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -227739,6 +257543,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -229098,6 +258932,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -229149,6 +259011,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -229200,6 +259082,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -229241,6 +259143,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -230419,6 +260351,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -230470,6 +260430,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -230521,6 +260501,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -230562,6 +260562,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -232391,6 +262421,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -232442,6 +262500,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -232493,6 +262571,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -232534,6 +262632,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -233915,6 +264043,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -233966,6 +264122,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -234017,6 +264193,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -234058,6 +264254,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -236383,6 +266609,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -236434,6 +266688,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -236485,6 +266759,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -236526,6 +266820,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -238742,6 +269066,34 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "$ref": "#/components/schemas/BulkOperationError" + } + ] + } + } + }, + "description": "The object could not be created. Where a list was submitted, no objects were created: a bulk creation is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to create one or more of the objects specified. No objects were created." } } }, @@ -238793,6 +269145,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -238844,6 +269216,26 @@ } }, "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be updated. No objects were modified: a bulk update is an all-or-none operation." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to apply one or more of the modifications specified. No objects were modified." } } }, @@ -238885,6 +269277,36 @@ "responses": { "204": { "description": "No response body" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The request was malformed, one or more of the objects specified could not be found, or the deletion of one of them was prevented by a protection rule. No objects were deleted." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "The requesting user is not permitted to delete one or more of the objects specified. No objects were deleted." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkOperationError" + } + } + }, + "description": "One or more of the objects specified could not be deleted, because a dependent object prevents it. No objects were deleted: a bulk deletion is an all-or-none operation." } } } @@ -241015,6 +271437,176 @@ "slug" ] }, + "BriefCoolingIntake": { + "type": "object", + "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "display": { + "type": "string", + "readOnly": true + }, + "device": { + "$ref": "#/components/schemas/BriefDevice" + }, + "name": { + "type": "string", + "maxLength": 64 + }, + "description": { + "type": "string", + "maxLength": 200 + } + }, + "required": [ + "device", + "display", + "id", + "name", + "url" + ] + }, + "BriefCoolingIntakeRequest": { + "type": "object", + "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", + "properties": { + "device": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefDeviceRequest" + } + ] + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "description": { + "type": "string", + "maxLength": 200 + } + }, + "required": [ + "device", + "name" + ] + }, + "BriefCoolingIntakeTemplate": { + "type": "object", + "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "display": { + "type": "string", + "readOnly": true + }, + "name": { + "type": "string", + "description": "{module} is accepted as a substitution for the module bay position when attached to a module type.", + "maxLength": 64 + }, + "description": { + "type": "string", + "maxLength": 200 + } + }, + "required": [ + "display", + "id", + "name", + "url" + ] + }, + "BriefCoolingIntakeTemplateRequest": { + "type": "object", + "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "{module} is accepted as a substitution for the module bay position when attached to a module type.", + "maxLength": 64 + }, + "description": { + "type": "string", + "maxLength": 200 + } + }, + "required": [ + "name" + ] + }, + "BriefCoolingSource": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "display": { + "type": "string", + "readOnly": true + }, + "name": { + "type": "string", + "maxLength": 100 + }, + "description": { + "type": "string", + "maxLength": 200 + } + }, + "required": [ + "display", + "id", + "name", + "url" + ] + }, + "BriefCoolingSourceRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "description": { + "type": "string", + "maxLength": 200 + } + }, + "required": [ + "name" + ] + }, "BriefCustomFieldChoiceSet": { "type": "object", "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", @@ -241826,7 +272418,7 @@ }, "BriefInterface": { "type": "object", - "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", + "description": "Mixin for Interface and VMInterface serializers that adds a `mac_address` shortcut field for\ncreating/updating the primary MACAddress in a single request. The validated write is centralized\non the interface model (BaseInterface.set_primary_mac_address); this mixin is a thin adapter that\nowns the permission check, the shortcut-vs-primary_mac_address conflict guard, and translating the\nmodel's validation errors into API errors.", "properties": { "id": { "type": "integer", @@ -241879,7 +272471,7 @@ }, "BriefInterfaceRequest": { "type": "object", - "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", + "description": "Mixin for Interface and VMInterface serializers that adds a `mac_address` shortcut field for\ncreating/updating the primary MACAddress in a single request. The validated write is centralized\non the interface model (BaseInterface.set_primary_mac_address); this mixin is a thin adapter that\nowns the permission check, the shortcut-vs-primary_mac_address conflict guard, and translating the\nmodel's validation errors into API errors.", "properties": { "device": { "oneOf": [ @@ -242446,6 +273038,66 @@ "url" ] }, + "BriefModuleBayType": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "display": { + "type": "string", + "readOnly": true + }, + "name": { + "type": "string", + "maxLength": 100 + }, + "slug": { + "type": "string", + "maxLength": 100, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "manufacturer": { + "allOf": [ + { + "$ref": "#/components/schemas/BriefManufacturer" + } + ], + "nullable": true + }, + "color": { + "oneOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{6}$", + "maxLength": 6 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "description": { + "type": "string", + "maxLength": 200 + } + }, + "required": [ + "display", + "id", + "name", + "slug", + "url" + ] + }, "BriefModuleRequest": { "type": "object", "description": "Base serializer class for models inheriting from PrimaryModel.", @@ -247211,6 +277863,780 @@ "slug" ] }, + "BulkCoolingFeedRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "id": { + "type": "integer" + }, + "cooling_source": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefCoolingSourceRequest" + } + ] + }, + "rack": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefRackRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "status": { + "enum": [ + "offline", + "active", + "planned", + "failed" + ], + "type": "string", + "description": "* `offline` - Offline\n* `active` - Active\n* `planned` - Planned\n* `failed` - Failed", + "x-spec-enum-id": "ec530572dc778583" + }, + "cooling_capacity": { + "type": "number", + "format": "double", + "maximum": 100000000, + "minimum": 0, + "exclusiveMaximum": true, + "nullable": true, + "description": "Rated cooling capacity (kW)" + }, + "max_flow": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "max_flow_unit": { + "enum": [ + "lpm", + "m3ph", + "gpm", + "", + null + ], + "type": "string", + "description": "* `lpm` - Liters per minute (L/min)\n* `m3ph` - Cubic meters per hour (m³/h)\n* `gpm` - Gallons per minute (GPM)", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "tenant": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefTenantRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "comments": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "cooling_source", + "id", + "name" + ] + }, + "BulkCoolingIntakeRequest": { + "type": "object", + "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", + "properties": { + "id": { + "type": "integer" + }, + "device": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefDeviceRequest" + } + ] + }, + "module": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefModuleRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "label": { + "type": "string", + "description": "Physical label", + "maxLength": 64 + }, + "type": { + "enum": [ + "uqd", + "uqdb", + "qdc", + "camlock", + "npt", + "bsp", + "proprietary", + "", + null + ], + "type": "string", + "description": "* `uqd` - UQD (Universal Quick Disconnect)\n* `uqdb` - UQDB (Universal Quick Disconnect, Blind-mate)\n* `qdc` - QDC (Quick Disconnect Coupling)\n* `camlock` - Camlock (cam-and-groove)\n* `npt` - NPT (threaded)\n* `bsp` - BSP (threaded)\n* `proprietary` - Proprietary", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + }, + "diameter": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "diameter_unit": { + "enum": [ + "mm", + "cm", + "in", + "", + null + ], + "type": "string", + "description": "* `mm` - Millimeters\n* `cm` - Centimeters\n* `in` - Inches", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + }, + "max_flow": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "max_flow_unit": { + "enum": [ + "lpm", + "m3ph", + "gpm", + "", + null + ], + "type": "string", + "description": "* `lpm` - Liters per minute (L/min)\n* `m3ph` - Cubic meters per hour (m³/h)\n* `gpm` - Gallons per minute (GPM)", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + }, + "cooling_outflow": { + "allOf": [ + { + "$ref": "#/components/schemas/NestedCoolingOutflowRequest" + } + ], + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "device", + "id", + "name" + ] + }, + "BulkCoolingIntakeTemplateRequest": { + "type": "object", + "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)", + "properties": { + "id": { + "type": "integer" + }, + "device_type": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefDeviceTypeRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "module_type": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefModuleTypeRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "description": "{module} is accepted as a substitution for the module bay position when attached to a module type.", + "maxLength": 64 + }, + "label": { + "type": "string", + "description": "Physical label", + "maxLength": 64 + }, + "type": { + "enum": [ + "uqd", + "uqdb", + "qdc", + "camlock", + "npt", + "bsp", + "proprietary", + "", + null + ], + "type": "string", + "description": "* `uqd` - UQD (Universal Quick Disconnect)\n* `uqdb` - UQDB (Universal Quick Disconnect, Blind-mate)\n* `qdc` - QDC (Quick Disconnect Coupling)\n* `camlock` - Camlock (cam-and-groove)\n* `npt` - NPT (threaded)\n* `bsp` - BSP (threaded)\n* `proprietary` - Proprietary", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + }, + "diameter": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "diameter_unit": { + "enum": [ + "mm", + "cm", + "in", + "", + null + ], + "type": "string", + "description": "* `mm` - Millimeters\n* `cm` - Centimeters\n* `in` - Inches", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + }, + "max_flow": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "max_flow_unit": { + "enum": [ + "lpm", + "m3ph", + "gpm", + "", + null + ], + "type": "string", + "description": "* `lpm` - Liters per minute (L/min)\n* `m3ph` - Cubic meters per hour (m³/h)\n* `gpm` - Gallons per minute (GPM)", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + } + }, + "required": [ + "id", + "name" + ] + }, + "BulkCoolingOutflowRequest": { + "type": "object", + "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", + "properties": { + "id": { + "type": "integer" + }, + "device": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefDeviceRequest" + } + ] + }, + "module": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefModuleRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "label": { + "type": "string", + "description": "Physical label", + "maxLength": 64 + }, + "type": { + "enum": [ + "uqd", + "uqdb", + "qdc", + "camlock", + "npt", + "bsp", + "proprietary", + "", + null + ], + "type": "string", + "description": "* `uqd` - UQD (Universal Quick Disconnect)\n* `uqdb` - UQDB (Universal Quick Disconnect, Blind-mate)\n* `qdc` - QDC (Quick Disconnect Coupling)\n* `camlock` - Camlock (cam-and-groove)\n* `npt` - NPT (threaded)\n* `bsp` - BSP (threaded)\n* `proprietary` - Proprietary", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + }, + "diameter": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "diameter_unit": { + "enum": [ + "mm", + "cm", + "in", + "", + null + ], + "type": "string", + "description": "* `mm` - Millimeters\n* `cm` - Centimeters\n* `in` - Inches", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + }, + "cooling_intake": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefCoolingIntakeRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "device", + "id", + "name" + ] + }, + "BulkCoolingOutflowTemplateRequest": { + "type": "object", + "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)", + "properties": { + "id": { + "type": "integer" + }, + "device_type": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefDeviceTypeRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "module_type": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefModuleTypeRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "description": "{module} is accepted as a substitution for the module bay position when attached to a module type.", + "maxLength": 64 + }, + "label": { + "type": "string", + "description": "Physical label", + "maxLength": 64 + }, + "type": { + "enum": [ + "uqd", + "uqdb", + "qdc", + "camlock", + "npt", + "bsp", + "proprietary", + "", + null + ], + "type": "string", + "description": "* `uqd` - UQD (Universal Quick Disconnect)\n* `uqdb` - UQDB (Universal Quick Disconnect, Blind-mate)\n* `qdc` - QDC (Quick Disconnect Coupling)\n* `camlock` - Camlock (cam-and-groove)\n* `npt` - NPT (threaded)\n* `bsp` - BSP (threaded)\n* `proprietary` - Proprietary", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + }, + "diameter": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "diameter_unit": { + "enum": [ + "mm", + "cm", + "in", + "", + null + ], + "type": "string", + "description": "* `mm` - Millimeters\n* `cm` - Centimeters\n* `in` - Inches", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + }, + "cooling_intake": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefCoolingIntakeTemplateRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + } + }, + "required": [ + "id", + "name" + ] + }, + "BulkCoolingSourceRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "id": { + "type": "integer" + }, + "site": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefSiteRequest" + } + ] + }, + "location": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefLocationRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "type": { + "enum": [ + "chiller", + "cooling-tower", + "dry-cooler", + "crac", + "crah" + ], + "type": "string", + "description": "* `chiller` - Chiller\n* `cooling-tower` - Cooling tower\n* `dry-cooler` - Dry cooler\n* `crac` - CRAC\n* `crah` - CRAH", + "x-spec-enum-id": "f225f830b0d77ac5" + }, + "status": { + "enum": [ + "offline", + "active", + "planned", + "failed" + ], + "type": "string", + "description": "* `offline` - Offline\n* `active` - Active\n* `planned` - Planned\n* `failed` - Failed", + "x-spec-enum-id": "ec530572dc778583" + }, + "fluid_type": { + "enum": [ + "water", + "water-glycol", + "dielectric", + "refrigerant", + "", + null + ], + "type": "string", + "description": "* `water` - Water\n* `water-glycol` - Water/glycol\n* `dielectric` - Dielectric\n* `refrigerant` - Refrigerant", + "x-spec-enum-id": "b558e2a3f7349765", + "nullable": true + }, + "cooling_capacity": { + "type": "number", + "format": "double", + "maximum": 100000000, + "minimum": 0, + "exclusiveMaximum": true, + "nullable": true, + "description": "Total rated cooling capacity (kW)" + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "comments": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "id", + "name", + "site", + "type" + ] + }, "BulkCustomFieldChoiceSetRequest": { "type": "object", "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", @@ -247328,8 +278754,8 @@ "multiobject" ], "type": "string", - "description": "* `text` - Text\n* `longtext` - Text (long)\n* `integer` - Integer\n* `decimal` - Decimal\n* `boolean` - Boolean (true/false)\n* `date` - Date\n* `datetime` - Date & time\n* `url` - URL\n* `json` - JSON\n* `select` - Selection\n* `multiselect` - Multiple selection\n* `object` - Object\n* `multiobject` - Multiple objects", - "x-spec-enum-id": "47c52a3d983e924c" + "description": "* `text` - Text\n* `longtext` - Text (long)\n* `integer` - Integer\n* `decimal` - Decimal\n* `boolean` - Boolean\n* `date` - Date\n* `datetime` - Date & time\n* `url` - URL\n* `json` - JSON\n* `select` - Selection\n* `multiselect` - Multiple selection\n* `object` - Object\n* `multiobject` - Multiple objects", + "x-spec-enum-id": "6ec6eff91d34cc44" }, "related_object_type": { "type": "string", @@ -247405,6 +278831,10 @@ "type": "boolean", "description": "Replicate this value when cloning objects" }, + "nulls_first": { + "type": "boolean", + "description": "Sort null values before non-null values when ordering by this field" + }, "default": { "nullable": true, "description": "Default value for the field (must be a JSON value). Encapsulate strings with double quotes (e.g. \"Foo\")." @@ -247801,6 +279231,359 @@ "name" ] }, + "BulkDeviceRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string", + "nullable": true, + "maxLength": 64 + }, + "device_type": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefDeviceTypeRequest" + } + ] + }, + "role": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefDeviceRoleRequest" + } + ] + }, + "tenant": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefTenantRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "platform": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefPlatformRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "serial": { + "type": "string", + "title": "Serial number", + "description": "Chassis serial number, assigned by the manufacturer", + "maxLength": 50 + }, + "asset_tag": { + "type": "string", + "nullable": true, + "description": "A unique tag used to identify this device", + "maxLength": 50 + }, + "site": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefSiteRequest" + } + ] + }, + "location": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefLocationRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "rack": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefRackRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "position": { + "type": "number", + "format": "double", + "maximum": 1000, + "minimum": 0.5, + "exclusiveMaximum": true, + "nullable": true, + "title": "Position (U)" + }, + "face": { + "enum": [ + "front", + "rear", + "" + ], + "type": "string", + "description": "* `front` - Front\n* `rear` - Rear", + "x-spec-enum-id": "d2fb9b3f75158b83" + }, + "latitude": { + "type": "number", + "format": "double", + "maximum": 90.0, + "minimum": -90.0, + "nullable": true, + "description": "GPS coordinate in decimal format (xx.yyyyyy)" + }, + "longitude": { + "type": "number", + "format": "double", + "maximum": 180.0, + "minimum": -180.0, + "nullable": true, + "description": "GPS coordinate in decimal format (xx.yyyyyy)" + }, + "status": { + "enum": [ + "offline", + "active", + "planned", + "staged", + "failed", + "inventory", + "decommissioning" + ], + "type": "string", + "description": "* `offline` - Offline\n* `active` - Active\n* `planned` - Planned\n* `staged` - Staged\n* `failed` - Failed\n* `inventory` - Inventory\n* `decommissioning` - Decommissioning", + "x-spec-enum-id": "65feb4244cc9110c" + }, + "airflow": { + "enum": [ + "front-to-rear", + "rear-to-front", + "left-to-right", + "right-to-left", + "side-to-rear", + "rear-to-side", + "bottom-to-top", + "top-to-bottom", + "passive", + "mixed", + "" + ], + "type": "string", + "description": "* `front-to-rear` - Front to rear\n* `rear-to-front` - Rear to front\n* `left-to-right` - Left to right\n* `right-to-left` - Right to left\n* `side-to-rear` - Side to rear\n* `rear-to-side` - Rear to side\n* `bottom-to-top` - Bottom to top\n* `top-to-bottom` - Top to bottom\n* `passive` - Passive\n* `mixed` - Mixed", + "x-spec-enum-id": "11cb3d363b41ba9e" + }, + "cooling_method": { + "enum": [ + "air", + "liquid", + "hybrid", + "immersion", + "", + null + ], + "type": "string", + "description": "* `air` - Air\n* `liquid` - Liquid\n* `hybrid` - Hybrid\n* `immersion` - Immersion", + "x-spec-enum-id": "fff5375d415a4fdc", + "nullable": true + }, + "primary_ip4": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefIPAddressRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "primary_ip6": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefIPAddressRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "oob_ip": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefIPAddressRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "cluster": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefClusterRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "virtual_chassis": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefVirtualChassisRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "vc_position": { + "type": "integer", + "maximum": 255, + "minimum": 0, + "nullable": true + }, + "vc_priority": { + "type": "integer", + "maximum": 255, + "minimum": 0, + "nullable": true, + "description": "Virtual chassis master election priority" + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "comments": { + "type": "string" + }, + "config_template": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefConfigTemplateRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "local_context_data": { + "nullable": true, + "description": "Local config context data takes precedence over source contexts in the final rendered config context" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "device_type", + "id", + "role", + "site" + ] + }, "BulkDeviceRoleRequest": { "type": "object", "description": "Base serializer class for models inheriting from NestedGroupModel.", @@ -247991,6 +279774,20 @@ "x-spec-enum-id": "11cb3d363b41ba9e", "nullable": true }, + "cooling_method": { + "enum": [ + "air", + "liquid", + "hybrid", + "immersion", + "", + null + ], + "type": "string", + "description": "* `air` - Air\n* `liquid` - Liquid\n* `hybrid` - Hybrid\n* `immersion` - Immersion", + "x-spec-enum-id": "fff5375d415a4fdc", + "nullable": true + }, "weight": { "type": "number", "format": "double", @@ -248014,6 +279811,12 @@ "x-spec-enum-id": "2235ce3f404afbc0", "nullable": true }, + "end_of_life": { + "type": "string", + "format": "date", + "nullable": true, + "description": "The date after which this device type is no longer supported by the manufacturer" + }, "front_image": { "type": "string", "format": "binary", @@ -248065,345 +279868,6 @@ "slug" ] }, - "BulkDeviceWithConfigContextRequest": { - "type": "object", - "description": "Base serializer class for models inheriting from PrimaryModel.", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string", - "nullable": true, - "maxLength": 64 - }, - "device_type": { - "oneOf": [ - { - "type": "integer" - }, - { - "$ref": "#/components/schemas/BriefDeviceTypeRequest" - } - ] - }, - "role": { - "oneOf": [ - { - "type": "integer" - }, - { - "$ref": "#/components/schemas/BriefDeviceRoleRequest" - } - ] - }, - "tenant": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefTenantRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "platform": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefPlatformRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "serial": { - "type": "string", - "title": "Serial number", - "description": "Chassis serial number, assigned by the manufacturer", - "maxLength": 50 - }, - "asset_tag": { - "type": "string", - "nullable": true, - "description": "A unique tag used to identify this device", - "maxLength": 50 - }, - "site": { - "oneOf": [ - { - "type": "integer" - }, - { - "$ref": "#/components/schemas/BriefSiteRequest" - } - ] - }, - "location": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefLocationRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "rack": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefRackRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "position": { - "type": "number", - "format": "double", - "maximum": 1000, - "minimum": 0.5, - "exclusiveMaximum": true, - "nullable": true, - "title": "Position (U)" - }, - "face": { - "enum": [ - "front", - "rear", - "" - ], - "type": "string", - "description": "* `front` - Front\n* `rear` - Rear", - "x-spec-enum-id": "d2fb9b3f75158b83" - }, - "latitude": { - "type": "number", - "format": "double", - "maximum": 90.0, - "minimum": -90.0, - "nullable": true, - "description": "GPS coordinate in decimal format (xx.yyyyyy)" - }, - "longitude": { - "type": "number", - "format": "double", - "maximum": 180.0, - "minimum": -180.0, - "nullable": true, - "description": "GPS coordinate in decimal format (xx.yyyyyy)" - }, - "status": { - "enum": [ - "offline", - "active", - "planned", - "staged", - "failed", - "inventory", - "decommissioning" - ], - "type": "string", - "description": "* `offline` - Offline\n* `active` - Active\n* `planned` - Planned\n* `staged` - Staged\n* `failed` - Failed\n* `inventory` - Inventory\n* `decommissioning` - Decommissioning", - "x-spec-enum-id": "65feb4244cc9110c" - }, - "airflow": { - "enum": [ - "front-to-rear", - "rear-to-front", - "left-to-right", - "right-to-left", - "side-to-rear", - "rear-to-side", - "bottom-to-top", - "top-to-bottom", - "passive", - "mixed", - "" - ], - "type": "string", - "description": "* `front-to-rear` - Front to rear\n* `rear-to-front` - Rear to front\n* `left-to-right` - Left to right\n* `right-to-left` - Right to left\n* `side-to-rear` - Side to rear\n* `rear-to-side` - Rear to side\n* `bottom-to-top` - Bottom to top\n* `top-to-bottom` - Top to bottom\n* `passive` - Passive\n* `mixed` - Mixed", - "x-spec-enum-id": "11cb3d363b41ba9e" - }, - "primary_ip4": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefIPAddressRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "primary_ip6": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefIPAddressRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "oob_ip": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefIPAddressRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "cluster": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefClusterRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "virtual_chassis": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefVirtualChassisRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "vc_position": { - "type": "integer", - "maximum": 255, - "minimum": 0, - "nullable": true - }, - "vc_priority": { - "type": "integer", - "maximum": 255, - "minimum": 0, - "nullable": true, - "description": "Virtual chassis master election priority" - }, - "description": { - "type": "string", - "maxLength": 200 - }, - "owner": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefOwnerRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "comments": { - "type": "string" - }, - "config_template": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefConfigTemplateRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "local_context_data": { - "nullable": true, - "description": "Local config context data takes precedence over source contexts in the final rendered config context" - }, - "tags": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NestedTagRequest" - } - }, - "custom_fields": { - "type": "object", - "additionalProperties": {} - } - }, - "required": [ - "device_type", - "id", - "role", - "site" - ] - }, "BulkEventRuleRequest": { "type": "object", "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", @@ -248458,7 +279922,8 @@ "x-spec-enum-id": "287901b937995956" }, "action_object_type": { - "type": "string" + "type": "string", + "nullable": true }, "action_object_id": { "type": "integer", @@ -248499,7 +279964,6 @@ } }, "required": [ - "action_object_type", "action_type", "event_types", "id", @@ -249859,7 +281323,7 @@ }, "BulkInterfaceRequest": { "type": "object", - "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", + "description": "Mixin for Interface and VMInterface serializers that adds a `mac_address` shortcut field for\ncreating/updating the primary MACAddress in a single request. The validated write is centralized\non the interface model (BaseInterface.set_primary_mac_address); this mixin is a thin adapter that\nowns the permission check, the shortcut-vs-primary_mac_address conflict guard, and translating the\nmodel's validation errors into API errors.", "properties": { "id": { "type": "integer" @@ -249911,6 +281375,7 @@ "virtual", "bridge", "lag", + "channel", "100base-fx", "100base-lfx", "100base-tx", @@ -250137,8 +281602,22 @@ "other" ], "type": "string", - "description": "* `virtual` - Virtual\n* `bridge` - Bridge\n* `lag` - Link Aggregation Group (LAG)\n* `100base-fx` - 100BASE-FX (10/100ME)\n* `100base-lfx` - 100BASE-LFX (10/100ME)\n* `100base-tx` - 100BASE-TX (10/100ME)\n* `100base-t1` - 100BASE-T1 (10/100ME)\n* `1000base-bx10-d` - 1000BASE-BX10-D (1GE BiDi Down)\n* `1000base-bx10-u` - 1000BASE-BX10-U (1GE BiDi Up)\n* `1000base-cwdm` - 1000BASE-CWDM (1GE)\n* `1000base-cx` - 1000BASE-CX (1GE DAC)\n* `1000base-dwdm` - 1000BASE-DWDM (1GE)\n* `1000base-ex` - 1000BASE-EX (1GE)\n* `1000base-lsx` - 1000BASE-LSX (1GE)\n* `1000base-lx` - 1000BASE-LX (1GE)\n* `1000base-lx10` - 1000BASE-LX10/LH (1GE)\n* `1000base-sx` - 1000BASE-SX (1GE)\n* `1000base-t` - 1000BASE-T (1GE)\n* `1000base-tx` - 1000BASE-TX (1GE)\n* `1000base-zx` - 1000BASE-ZX (1GE)\n* `2.5gbase-t` - 2.5GBASE-T (2.5GE)\n* `5gbase-t` - 5GBASE-T (5GE)\n* `10gbase-br-d` - 10GBASE-BR-D (10GE BiDi Down)\n* `10gbase-br-u` - 10GBASE-BR-U (10GE BiDi Up)\n* `10gbase-cu` - 10GBASE-CU (10GE DAC Passive Twinax)\n* `10gbase-cx4` - 10GBASE-CX4 (10GE DAC)\n* `10gbase-er` - 10GBASE-ER (10GE)\n* `10gbase-lr` - 10GBASE-LR (10GE)\n* `10gbase-lrm` - 10GBASE-LRM (10GE)\n* `10gbase-lx4` - 10GBASE-LX4 (10GE)\n* `10gbase-sr` - 10GBASE-SR (10GE)\n* `10gbase-t` - 10GBASE-T (10GE)\n* `10gbase-zr` - 10GBASE-ZR (10GE)\n* `25gbase-cr` - 25GBASE-CR (25GE DAC)\n* `25gbase-er` - 25GBASE-ER (25GE)\n* `25gbase-lr` - 25GBASE-LR (25GE)\n* `25gbase-sr` - 25GBASE-SR (25GE)\n* `25gbase-t` - 25GBASE-T (25GE)\n* `40gbase-cr4` - 40GBASE-CR4 (40GE DAC)\n* `40gbase-er4` - 40GBASE-ER4 (40GE)\n* `40gbase-fr4` - 40GBASE-FR4 (40GE)\n* `40gbase-lr4` - 40GBASE-LR4 (40GE)\n* `40gbase-sr4` - 40GBASE-SR4 (40GE)\n* `40gbase-sr4-bd` - 40GBASE-SR4 (40GE BiDi)\n* `50gbase-cr` - 50GBASE-CR (50GE DAC)\n* `50gbase-er` - 50GBASE-ER (50GE)\n* `50gbase-fr` - 50GBASE-FR (50GE)\n* `50gbase-lr` - 50GBASE-LR (50GE)\n* `50gbase-sr` - 50GBASE-SR (50GE)\n* `100gbase-cr1` - 100GBASE-CR1 (100GE DAC)\n* `100gbase-cr2` - 100GBASE-CR2 (100GE DAC)\n* `100gbase-cr4` - 100GBASE-CR4 (100GE DAC)\n* `100gbase-cr10` - 100GBASE-CR10 (100GE DAC)\n* `100gbase-cwdm4` - 100GBASE-CWDM4 (100GE)\n* `100gbase-dr` - 100GBASE-DR (100GE)\n* `100gbase-er4` - 100GBASE-ER4 (100GE)\n* `100gbase-fr1` - 100GBASE-FR1 (100GE)\n* `100gbase-lr1` - 100GBASE-LR1 (100GE)\n* `100gbase-lr4` - 100GBASE-LR4 (100GE)\n* `100gbase-sr1` - 100GBASE-SR1 (100GE)\n* `100gbase-sr1.2` - 100GBASE-SR1.2 (100GE BiDi)\n* `100gbase-sr2` - 100GBASE-SR2 (100GE)\n* `100gbase-sr4` - 100GBASE-SR4 (100GE)\n* `100gbase-sr10` - 100GBASE-SR10 (100GE)\n* `100gbase-zr` - 100GBASE-ZR (100GE)\n* `200gbase-cr2` - 200GBASE-CR2 (200GE)\n* `200gbase-cr4` - 200GBASE-CR4 (200GE)\n* `200gbase-dr4` - 200GBASE-DR4 (200GE)\n* `200gbase-er4` - 200GBASE-ER4 (200GE)\n* `200gbase-fr4` - 200GBASE-FR4 (200GE)\n* `200gbase-lr4` - 200GBASE-LR4 (200GE)\n* `200gbase-sr2` - 200GBASE-SR2 (200GE)\n* `200gbase-sr4` - 200GBASE-SR4 (200GE)\n* `200gbase-vr2` - 200GBASE-VR2 (200GE)\n* `400gbase-cr4` - 400GBASE-CR4 (400GE)\n* `400gbase-dr4` - 400GBASE-DR4 (400GE)\n* `400gbase-er8` - 400GBASE-ER8 (400GE)\n* `400gbase-fr4` - 400GBASE-FR4 (400GE)\n* `400gbase-fr8` - 400GBASE-FR8 (400GE)\n* `400gbase-lr4` - 400GBASE-LR4 (400GE)\n* `400gbase-lr8` - 400GBASE-LR8 (400GE)\n* `400gbase-sr4` - 400GBASE-SR4 (400GE)\n* `400gbase-sr4_2` - 400GBASE-SR4.2 (400GE BiDi)\n* `400gbase-sr8` - 400GBASE-SR8 (400GE)\n* `400gbase-sr16` - 400GBASE-SR16 (400GE)\n* `400gbase-vr4` - 400GBASE-VR4 (400GE)\n* `400gbase-zr` - 400GBASE-ZR (400GE)\n* `800gbase-cr8` - 800GBASE-CR8 (800GE)\n* `800gbase-dr8` - 800GBASE-DR8 (800GE)\n* `800gbase-sr8` - 800GBASE-SR8 (800GE)\n* `800gbase-vr8` - 800GBASE-VR8 (800GE)\n* `1.6tbase-cr8` - 1.6TBASE-CR8 (1.6TE)\n* `1.6tbase-dr8` - 1.6TBASE-DR8 (1.6TE)\n* `1.6tbase-dr8-2` - 1.6TBASE-DR8-2 (1.6TE)\n* `100base-x-sfp` - SFP (100ME)\n* `1000base-x-gbic` - GBIC (1GE)\n* `1000base-x-sfp` - SFP (1GE)\n* `2.5gbase-x-sfp` - SFP (2.5GE)\n* `10gbase-x-sfpp` - SFP+ (10GE)\n* `10gbase-x-xenpak` - XENPAK (10GE)\n* `10gbase-x-xfp` - XFP (10GE)\n* `10gbase-x-x2` - X2 (10GE)\n* `25gbase-x-sfp28` - SFP28 (25GE)\n* `40gbase-x-qsfpp` - QSFP+ (40GE)\n* `50gbase-x-sfp28` - QSFP28 (50GE)\n* `50gbase-x-sfp56` - SFP56 (50GE)\n* `100gbase-x-cfp` - CFP (100GE)\n* `100gbase-x-cfp2` - CFP2 (100GE)\n* `100gbase-x-cfp4` - CFP4 (100GE)\n* `100gbase-x-cxp` - CXP (100GE)\n* `100gbase-x-cpak` - Cisco CPAK (100GE)\n* `100gbase-x-dsfp` - DSFP (100GE)\n* `100gbase-x-qsfp28` - QSFP28 (100GE)\n* `100gbase-x-qsfpdd` - QSFP-DD (100GE)\n* `100gbase-x-sfp112` - SFP112 (100GE)\n* `100gbase-x-sfpdd` - SFP-DD (100GE)\n* `200gbase-x-cfp2` - CFP2 (200GE)\n* `200gbase-x-qsfp56` - QSFP56 (200GE)\n* `200gbase-x-qsfpdd` - QSFP-DD (200GE)\n* `400gbase-x-qsfp112` - QSFP112 (400GE)\n* `400gbase-x-qsfpdd` - QSFP-DD (400GE)\n* `400gbase-x-cdfp` - CDFP (400GE)\n* `400gbase-x-cfp2` - CFP2 (400GE)\n* `400gbase-x-cfp8` - CPF8 (400GE)\n* `400gbase-x-osfp` - OSFP (400GE)\n* `400gbase-x-osfp-rhs` - OSFP-RHS (400GE)\n* `800gbase-x-osfp` - OSFP (800GE)\n* `800gbase-x-qsfpdd` - QSFP-DD (800GE)\n* `1.6tbase-x-osfp1600` - OSFP1600 (1.6TE)\n* `1.6tbase-x-osfp1600-rhs` - OSFP1600-RHS (1.6TE)\n* `1.6tbase-x-qsfpdd1600` - QSFP-DD1600 (1.6TE)\n* `1000base-kx` - 1000BASE-KX (1GE)\n* `2.5gbase-kx` - 2.5GBASE-KX (2.5GE)\n* `5gbase-kr` - 5GBASE-KR (5GE)\n* `10gbase-kr` - 10GBASE-KR (10GE)\n* `10gbase-kx4` - 10GBASE-KX4 (10GE)\n* `25gbase-kr` - 25GBASE-KR (25GE)\n* `40gbase-kr4` - 40GBASE-KR4 (40GE)\n* `50gbase-kr` - 50GBASE-KR (50GE)\n* `100gbase-kp4` - 100GBASE-KP4 (100GE)\n* `100gbase-kr2` - 100GBASE-KR2 (100GE)\n* `100gbase-kr4` - 100GBASE-KR4 (100GE)\n* `1.6tbase-kr8` - 1.6TBASE-KR8 (1.6TE)\n* `ieee802.11a` - IEEE 802.11a\n* `ieee802.11g` - IEEE 802.11b/g\n* `ieee802.11n` - IEEE 802.11n (Wi-Fi 4)\n* `ieee802.11ac` - IEEE 802.11ac (Wi-Fi 5)\n* `ieee802.11ad` - IEEE 802.11ad (WiGig)\n* `ieee802.11ax` - IEEE 802.11ax (Wi-Fi 6)\n* `ieee802.11ay` - IEEE 802.11ay (WiGig)\n* `ieee802.11be` - IEEE 802.11be (Wi-Fi 7)\n* `ieee802.15.1` - IEEE 802.15.1 (Bluetooth)\n* `ieee802.15.4` - IEEE 802.15.4 (LR-WPAN)\n* `other-wireless` - Other (Wireless)\n* `gsm` - GSM\n* `cdma` - CDMA\n* `lte` - LTE\n* `4g` - 4G\n* `5g` - 5G\n* `sonet-oc3` - OC-3/STM-1\n* `sonet-oc12` - OC-12/STM-4\n* `sonet-oc48` - OC-48/STM-16\n* `sonet-oc192` - OC-192/STM-64\n* `sonet-oc768` - OC-768/STM-256\n* `sonet-oc1920` - OC-1920/STM-640\n* `sonet-oc3840` - OC-3840/STM-1234\n* `1gfc-sfp` - SFP (1GFC)\n* `2gfc-sfp` - SFP (2GFC)\n* `4gfc-sfp` - SFP (4GFC)\n* `8gfc-sfpp` - SFP+ (8GFC)\n* `16gfc-sfpp` - SFP+ (16GFC)\n* `32gfc-sfp28` - SFP28 (32GFC)\n* `32gfc-sfpp` - SFP+ (32GFC)\n* `64gfc-qsfpp` - QSFP+ (64GFC)\n* `64gfc-sfpdd` - SFP-DD (64GFC)\n* `64gfc-sfpp` - SFP+ (64GFC)\n* `128gfc-qsfp28` - QSFP28 (128GFC)\n* `infiniband-sdr` - SDR (2 Gbps)\n* `infiniband-ddr` - DDR (4 Gbps)\n* `infiniband-qdr` - QDR (8 Gbps)\n* `infiniband-fdr10` - FDR10 (10 Gbps)\n* `infiniband-fdr` - FDR (13.5 Gbps)\n* `infiniband-edr` - EDR (25 Gbps)\n* `infiniband-hdr` - HDR (50 Gbps)\n* `infiniband-ndr` - NDR (100 Gbps)\n* `infiniband-xdr` - XDR (200 Gbps)\n* `infiniband-sdr-4x` - SDR 4X (8 Gbps)\n* `infiniband-ddr-4x` - DDR 4X (16 Gbps)\n* `infiniband-qdr-4x` - QDR 4X (32 Gbps)\n* `infiniband-fdr10-4x` - FDR10 4X (40 Gbps)\n* `infiniband-fdr-4x` - FDR 4X (56 Gbps)\n* `infiniband-edr-4x` - EDR 4X (100 Gbps)\n* `infiniband-hdr-4x` - HDR 4X (200 Gbps)\n* `infiniband-ndr-4x` - NDR 4X (400 Gbps)\n* `infiniband-xdr-4x` - XDR 4X (800 Gbps)\n* `t1` - T1 (1.544 Mbps)\n* `e1` - E1 (2.048 Mbps)\n* `t3` - T3 (45 Mbps)\n* `e3` - E3 (34 Mbps)\n* `xdsl` - xDSL\n* `docsis` - DOCSIS\n* `moca` - MoCA\n* `bpon` - BPON (622 Mbps / 155 Mbps)\n* `epon` - EPON (1 Gbps)\n* `10g-epon` - 10G-EPON (10 Gbps)\n* `gpon` - GPON (2.5 Gbps / 1.25 Gbps)\n* `xg-pon` - XG-PON (10 Gbps / 2.5 Gbps)\n* `xgs-pon` - XGS-PON (10 Gbps)\n* `ng-pon2` - NG-PON2 (TWDM-PON) (4x10 Gbps)\n* `25g-pon` - 25G-PON (25 Gbps)\n* `50g-pon` - 50G-PON (50 Gbps)\n* `cisco-stackwise` - Cisco StackWise\n* `cisco-stackwise-plus` - Cisco StackWise Plus\n* `cisco-flexstack` - Cisco FlexStack\n* `cisco-flexstack-plus` - Cisco FlexStack Plus\n* `cisco-stackwise-80` - Cisco StackWise-80\n* `cisco-stackwise-160` - Cisco StackWise-160\n* `cisco-stackwise-320` - Cisco StackWise-320\n* `cisco-stackwise-480` - Cisco StackWise-480\n* `cisco-stackwise-1t` - Cisco StackWise-1T\n* `juniper-vcp` - Juniper VCP\n* `extreme-summitstack` - Extreme SummitStack\n* `extreme-summitstack-128` - Extreme SummitStack-128\n* `extreme-summitstack-256` - Extreme SummitStack-256\n* `extreme-summitstack-512` - Extreme SummitStack-512\n* `hpe-synergy-interconnect-link` - HPE Synergy Interconnect Link\n* `other` - Other", - "x-spec-enum-id": "b0c97040e5abdff1" + "description": "* `virtual` - Virtual\n* `bridge` - Bridge\n* `lag` - Link Aggregation Group (LAG)\n* `channel` - Channel\n* `100base-fx` - 100BASE-FX (10/100ME)\n* `100base-lfx` - 100BASE-LFX (10/100ME)\n* `100base-tx` - 100BASE-TX (10/100ME)\n* `100base-t1` - 100BASE-T1 (10/100ME)\n* `1000base-bx10-d` - 1000BASE-BX10-D (1GE BiDi Down)\n* `1000base-bx10-u` - 1000BASE-BX10-U (1GE BiDi Up)\n* `1000base-cwdm` - 1000BASE-CWDM (1GE)\n* `1000base-cx` - 1000BASE-CX (1GE DAC)\n* `1000base-dwdm` - 1000BASE-DWDM (1GE)\n* `1000base-ex` - 1000BASE-EX (1GE)\n* `1000base-lsx` - 1000BASE-LSX (1GE)\n* `1000base-lx` - 1000BASE-LX (1GE)\n* `1000base-lx10` - 1000BASE-LX10/LH (1GE)\n* `1000base-sx` - 1000BASE-SX (1GE)\n* `1000base-t` - 1000BASE-T (1GE)\n* `1000base-tx` - 1000BASE-TX (1GE)\n* `1000base-zx` - 1000BASE-ZX (1GE)\n* `2.5gbase-t` - 2.5GBASE-T (2.5GE)\n* `5gbase-t` - 5GBASE-T (5GE)\n* `10gbase-br-d` - 10GBASE-BR-D (10GE BiDi Down)\n* `10gbase-br-u` - 10GBASE-BR-U (10GE BiDi Up)\n* `10gbase-cu` - 10GBASE-CU (10GE DAC Passive Twinax)\n* `10gbase-cx4` - 10GBASE-CX4 (10GE DAC)\n* `10gbase-er` - 10GBASE-ER (10GE)\n* `10gbase-lr` - 10GBASE-LR (10GE)\n* `10gbase-lrm` - 10GBASE-LRM (10GE)\n* `10gbase-lx4` - 10GBASE-LX4 (10GE)\n* `10gbase-sr` - 10GBASE-SR (10GE)\n* `10gbase-t` - 10GBASE-T (10GE)\n* `10gbase-zr` - 10GBASE-ZR (10GE)\n* `25gbase-cr` - 25GBASE-CR (25GE DAC)\n* `25gbase-er` - 25GBASE-ER (25GE)\n* `25gbase-lr` - 25GBASE-LR (25GE)\n* `25gbase-sr` - 25GBASE-SR (25GE)\n* `25gbase-t` - 25GBASE-T (25GE)\n* `40gbase-cr4` - 40GBASE-CR4 (40GE DAC)\n* `40gbase-er4` - 40GBASE-ER4 (40GE)\n* `40gbase-fr4` - 40GBASE-FR4 (40GE)\n* `40gbase-lr4` - 40GBASE-LR4 (40GE)\n* `40gbase-sr4` - 40GBASE-SR4 (40GE)\n* `40gbase-sr4-bd` - 40GBASE-SR4 (40GE BiDi)\n* `50gbase-cr` - 50GBASE-CR (50GE DAC)\n* `50gbase-er` - 50GBASE-ER (50GE)\n* `50gbase-fr` - 50GBASE-FR (50GE)\n* `50gbase-lr` - 50GBASE-LR (50GE)\n* `50gbase-sr` - 50GBASE-SR (50GE)\n* `100gbase-cr1` - 100GBASE-CR1 (100GE DAC)\n* `100gbase-cr2` - 100GBASE-CR2 (100GE DAC)\n* `100gbase-cr4` - 100GBASE-CR4 (100GE DAC)\n* `100gbase-cr10` - 100GBASE-CR10 (100GE DAC)\n* `100gbase-cwdm4` - 100GBASE-CWDM4 (100GE)\n* `100gbase-dr` - 100GBASE-DR (100GE)\n* `100gbase-er4` - 100GBASE-ER4 (100GE)\n* `100gbase-fr1` - 100GBASE-FR1 (100GE)\n* `100gbase-lr1` - 100GBASE-LR1 (100GE)\n* `100gbase-lr4` - 100GBASE-LR4 (100GE)\n* `100gbase-sr1` - 100GBASE-SR1 (100GE)\n* `100gbase-sr1.2` - 100GBASE-SR1.2 (100GE BiDi)\n* `100gbase-sr2` - 100GBASE-SR2 (100GE)\n* `100gbase-sr4` - 100GBASE-SR4 (100GE)\n* `100gbase-sr10` - 100GBASE-SR10 (100GE)\n* `100gbase-zr` - 100GBASE-ZR (100GE)\n* `200gbase-cr2` - 200GBASE-CR2 (200GE)\n* `200gbase-cr4` - 200GBASE-CR4 (200GE)\n* `200gbase-dr4` - 200GBASE-DR4 (200GE)\n* `200gbase-er4` - 200GBASE-ER4 (200GE)\n* `200gbase-fr4` - 200GBASE-FR4 (200GE)\n* `200gbase-lr4` - 200GBASE-LR4 (200GE)\n* `200gbase-sr2` - 200GBASE-SR2 (200GE)\n* `200gbase-sr4` - 200GBASE-SR4 (200GE)\n* `200gbase-vr2` - 200GBASE-VR2 (200GE)\n* `400gbase-cr4` - 400GBASE-CR4 (400GE)\n* `400gbase-dr4` - 400GBASE-DR4 (400GE)\n* `400gbase-er8` - 400GBASE-ER8 (400GE)\n* `400gbase-fr4` - 400GBASE-FR4 (400GE)\n* `400gbase-fr8` - 400GBASE-FR8 (400GE)\n* `400gbase-lr4` - 400GBASE-LR4 (400GE)\n* `400gbase-lr8` - 400GBASE-LR8 (400GE)\n* `400gbase-sr4` - 400GBASE-SR4 (400GE)\n* `400gbase-sr4_2` - 400GBASE-SR4.2 (400GE BiDi)\n* `400gbase-sr8` - 400GBASE-SR8 (400GE)\n* `400gbase-sr16` - 400GBASE-SR16 (400GE)\n* `400gbase-vr4` - 400GBASE-VR4 (400GE)\n* `400gbase-zr` - 400GBASE-ZR (400GE)\n* `800gbase-cr8` - 800GBASE-CR8 (800GE)\n* `800gbase-dr8` - 800GBASE-DR8 (800GE)\n* `800gbase-sr8` - 800GBASE-SR8 (800GE)\n* `800gbase-vr8` - 800GBASE-VR8 (800GE)\n* `1.6tbase-cr8` - 1.6TBASE-CR8 (1.6TE)\n* `1.6tbase-dr8` - 1.6TBASE-DR8 (1.6TE)\n* `1.6tbase-dr8-2` - 1.6TBASE-DR8-2 (1.6TE)\n* `100base-x-sfp` - SFP (100ME)\n* `1000base-x-gbic` - GBIC (1GE)\n* `1000base-x-sfp` - SFP (1GE)\n* `2.5gbase-x-sfp` - SFP (2.5GE)\n* `10gbase-x-sfpp` - SFP+ (10GE)\n* `10gbase-x-xenpak` - XENPAK (10GE)\n* `10gbase-x-xfp` - XFP (10GE)\n* `10gbase-x-x2` - X2 (10GE)\n* `25gbase-x-sfp28` - SFP28 (25GE)\n* `40gbase-x-qsfpp` - QSFP+ (40GE)\n* `50gbase-x-sfp28` - QSFP28 (50GE)\n* `50gbase-x-sfp56` - SFP56 (50GE)\n* `100gbase-x-cfp` - CFP (100GE)\n* `100gbase-x-cfp2` - CFP2 (100GE)\n* `100gbase-x-cfp4` - CFP4 (100GE)\n* `100gbase-x-cxp` - CXP (100GE)\n* `100gbase-x-cpak` - Cisco CPAK (100GE)\n* `100gbase-x-dsfp` - DSFP (100GE)\n* `100gbase-x-qsfp28` - QSFP28 (100GE)\n* `100gbase-x-qsfpdd` - QSFP-DD (100GE)\n* `100gbase-x-sfp112` - SFP112 (100GE)\n* `100gbase-x-sfpdd` - SFP-DD (100GE)\n* `200gbase-x-cfp2` - CFP2 (200GE)\n* `200gbase-x-qsfp56` - QSFP56 (200GE)\n* `200gbase-x-qsfpdd` - QSFP-DD (200GE)\n* `400gbase-x-qsfp112` - QSFP112 (400GE)\n* `400gbase-x-qsfpdd` - QSFP-DD (400GE)\n* `400gbase-x-cdfp` - CDFP (400GE)\n* `400gbase-x-cfp2` - CFP2 (400GE)\n* `400gbase-x-cfp8` - CPF8 (400GE)\n* `400gbase-x-osfp` - OSFP (400GE)\n* `400gbase-x-osfp-rhs` - OSFP-RHS (400GE)\n* `800gbase-x-osfp` - OSFP (800GE)\n* `800gbase-x-qsfpdd` - QSFP-DD (800GE)\n* `1.6tbase-x-osfp1600` - OSFP1600 (1.6TE)\n* `1.6tbase-x-osfp1600-rhs` - OSFP1600-RHS (1.6TE)\n* `1.6tbase-x-qsfpdd1600` - QSFP-DD1600 (1.6TE)\n* `1000base-kx` - 1000BASE-KX (1GE)\n* `2.5gbase-kx` - 2.5GBASE-KX (2.5GE)\n* `5gbase-kr` - 5GBASE-KR (5GE)\n* `10gbase-kr` - 10GBASE-KR (10GE)\n* `10gbase-kx4` - 10GBASE-KX4 (10GE)\n* `25gbase-kr` - 25GBASE-KR (25GE)\n* `40gbase-kr4` - 40GBASE-KR4 (40GE)\n* `50gbase-kr` - 50GBASE-KR (50GE)\n* `100gbase-kp4` - 100GBASE-KP4 (100GE)\n* `100gbase-kr2` - 100GBASE-KR2 (100GE)\n* `100gbase-kr4` - 100GBASE-KR4 (100GE)\n* `1.6tbase-kr8` - 1.6TBASE-KR8 (1.6TE)\n* `ieee802.11a` - IEEE 802.11a\n* `ieee802.11g` - IEEE 802.11b/g\n* `ieee802.11n` - IEEE 802.11n (Wi-Fi 4)\n* `ieee802.11ac` - IEEE 802.11ac (Wi-Fi 5)\n* `ieee802.11ad` - IEEE 802.11ad (WiGig)\n* `ieee802.11ax` - IEEE 802.11ax (Wi-Fi 6)\n* `ieee802.11ay` - IEEE 802.11ay (WiGig)\n* `ieee802.11be` - IEEE 802.11be (Wi-Fi 7)\n* `ieee802.15.1` - IEEE 802.15.1 (Bluetooth)\n* `ieee802.15.4` - IEEE 802.15.4 (LR-WPAN)\n* `other-wireless` - Other (Wireless)\n* `gsm` - GSM\n* `cdma` - CDMA\n* `lte` - LTE\n* `4g` - 4G\n* `5g` - 5G\n* `sonet-oc3` - OC-3/STM-1\n* `sonet-oc12` - OC-12/STM-4\n* `sonet-oc48` - OC-48/STM-16\n* `sonet-oc192` - OC-192/STM-64\n* `sonet-oc768` - OC-768/STM-256\n* `sonet-oc1920` - OC-1920/STM-640\n* `sonet-oc3840` - OC-3840/STM-1234\n* `1gfc-sfp` - SFP (1GFC)\n* `2gfc-sfp` - SFP (2GFC)\n* `4gfc-sfp` - SFP (4GFC)\n* `8gfc-sfpp` - SFP+ (8GFC)\n* `16gfc-sfpp` - SFP+ (16GFC)\n* `32gfc-sfp28` - SFP28 (32GFC)\n* `32gfc-sfpp` - SFP+ (32GFC)\n* `64gfc-qsfpp` - QSFP+ (64GFC)\n* `64gfc-sfpdd` - SFP-DD (64GFC)\n* `64gfc-sfpp` - SFP+ (64GFC)\n* `128gfc-qsfp28` - QSFP28 (128GFC)\n* `infiniband-sdr` - SDR (2 Gbps)\n* `infiniband-ddr` - DDR (4 Gbps)\n* `infiniband-qdr` - QDR (8 Gbps)\n* `infiniband-fdr10` - FDR10 (10 Gbps)\n* `infiniband-fdr` - FDR (13.5 Gbps)\n* `infiniband-edr` - EDR (25 Gbps)\n* `infiniband-hdr` - HDR (50 Gbps)\n* `infiniband-ndr` - NDR (100 Gbps)\n* `infiniband-xdr` - XDR (200 Gbps)\n* `infiniband-sdr-4x` - SDR 4X (8 Gbps)\n* `infiniband-ddr-4x` - DDR 4X (16 Gbps)\n* `infiniband-qdr-4x` - QDR 4X (32 Gbps)\n* `infiniband-fdr10-4x` - FDR10 4X (40 Gbps)\n* `infiniband-fdr-4x` - FDR 4X (56 Gbps)\n* `infiniband-edr-4x` - EDR 4X (100 Gbps)\n* `infiniband-hdr-4x` - HDR 4X (200 Gbps)\n* `infiniband-ndr-4x` - NDR 4X (400 Gbps)\n* `infiniband-xdr-4x` - XDR 4X (800 Gbps)\n* `t1` - T1 (1.544 Mbps)\n* `e1` - E1 (2.048 Mbps)\n* `t3` - T3 (45 Mbps)\n* `e3` - E3 (34 Mbps)\n* `xdsl` - xDSL\n* `docsis` - DOCSIS\n* `moca` - MoCA\n* `bpon` - BPON (622 Mbps / 155 Mbps)\n* `epon` - EPON (1 Gbps)\n* `10g-epon` - 10G-EPON (10 Gbps)\n* `gpon` - GPON (2.5 Gbps / 1.25 Gbps)\n* `xg-pon` - XG-PON (10 Gbps / 2.5 Gbps)\n* `xgs-pon` - XGS-PON (10 Gbps)\n* `ng-pon2` - NG-PON2 (TWDM-PON) (4x10 Gbps)\n* `25g-pon` - 25G-PON (25 Gbps)\n* `50g-pon` - 50G-PON (50 Gbps)\n* `cisco-stackwise` - Cisco StackWise\n* `cisco-stackwise-plus` - Cisco StackWise Plus\n* `cisco-flexstack` - Cisco FlexStack\n* `cisco-flexstack-plus` - Cisco FlexStack Plus\n* `cisco-stackwise-80` - Cisco StackWise-80\n* `cisco-stackwise-160` - Cisco StackWise-160\n* `cisco-stackwise-320` - Cisco StackWise-320\n* `cisco-stackwise-480` - Cisco StackWise-480\n* `cisco-stackwise-1t` - Cisco StackWise-1T\n* `juniper-vcp` - Juniper VCP\n* `extreme-summitstack` - Extreme SummitStack\n* `extreme-summitstack-128` - Extreme SummitStack-128\n* `extreme-summitstack-256` - Extreme SummitStack-256\n* `extreme-summitstack-512` - Extreme SummitStack-512\n* `hpe-synergy-interconnect-link` - HPE Synergy Interconnect Link\n* `other` - Other", + "x-spec-enum-id": "19cc901fcea417ad" + }, + "channels": { + "type": "integer", + "maximum": 1024, + "minimum": 1, + "nullable": true, + "description": "The number of channels into which this interface is channelized" + }, + "channel_id": { + "type": "integer", + "maximum": 1024, + "minimum": 1, + "nullable": true, + "description": "The channel on the parent interface to which this subinterface is bound" }, "enabled": { "type": "boolean" @@ -250173,6 +281652,11 @@ "minimum": 1, "nullable": true }, + "mac_address": { + "type": "string", + "nullable": true, + "minLength": 1 + }, "primary_mac_address": { "oneOf": [ { @@ -250674,6 +282158,7 @@ "virtual", "bridge", "lag", + "channel", "100base-fx", "100base-lfx", "100base-tx", @@ -250900,8 +282385,22 @@ "other" ], "type": "string", - "description": "* `virtual` - Virtual\n* `bridge` - Bridge\n* `lag` - Link Aggregation Group (LAG)\n* `100base-fx` - 100BASE-FX (10/100ME)\n* `100base-lfx` - 100BASE-LFX (10/100ME)\n* `100base-tx` - 100BASE-TX (10/100ME)\n* `100base-t1` - 100BASE-T1 (10/100ME)\n* `1000base-bx10-d` - 1000BASE-BX10-D (1GE BiDi Down)\n* `1000base-bx10-u` - 1000BASE-BX10-U (1GE BiDi Up)\n* `1000base-cwdm` - 1000BASE-CWDM (1GE)\n* `1000base-cx` - 1000BASE-CX (1GE DAC)\n* `1000base-dwdm` - 1000BASE-DWDM (1GE)\n* `1000base-ex` - 1000BASE-EX (1GE)\n* `1000base-lsx` - 1000BASE-LSX (1GE)\n* `1000base-lx` - 1000BASE-LX (1GE)\n* `1000base-lx10` - 1000BASE-LX10/LH (1GE)\n* `1000base-sx` - 1000BASE-SX (1GE)\n* `1000base-t` - 1000BASE-T (1GE)\n* `1000base-tx` - 1000BASE-TX (1GE)\n* `1000base-zx` - 1000BASE-ZX (1GE)\n* `2.5gbase-t` - 2.5GBASE-T (2.5GE)\n* `5gbase-t` - 5GBASE-T (5GE)\n* `10gbase-br-d` - 10GBASE-BR-D (10GE BiDi Down)\n* `10gbase-br-u` - 10GBASE-BR-U (10GE BiDi Up)\n* `10gbase-cu` - 10GBASE-CU (10GE DAC Passive Twinax)\n* `10gbase-cx4` - 10GBASE-CX4 (10GE DAC)\n* `10gbase-er` - 10GBASE-ER (10GE)\n* `10gbase-lr` - 10GBASE-LR (10GE)\n* `10gbase-lrm` - 10GBASE-LRM (10GE)\n* `10gbase-lx4` - 10GBASE-LX4 (10GE)\n* `10gbase-sr` - 10GBASE-SR (10GE)\n* `10gbase-t` - 10GBASE-T (10GE)\n* `10gbase-zr` - 10GBASE-ZR (10GE)\n* `25gbase-cr` - 25GBASE-CR (25GE DAC)\n* `25gbase-er` - 25GBASE-ER (25GE)\n* `25gbase-lr` - 25GBASE-LR (25GE)\n* `25gbase-sr` - 25GBASE-SR (25GE)\n* `25gbase-t` - 25GBASE-T (25GE)\n* `40gbase-cr4` - 40GBASE-CR4 (40GE DAC)\n* `40gbase-er4` - 40GBASE-ER4 (40GE)\n* `40gbase-fr4` - 40GBASE-FR4 (40GE)\n* `40gbase-lr4` - 40GBASE-LR4 (40GE)\n* `40gbase-sr4` - 40GBASE-SR4 (40GE)\n* `40gbase-sr4-bd` - 40GBASE-SR4 (40GE BiDi)\n* `50gbase-cr` - 50GBASE-CR (50GE DAC)\n* `50gbase-er` - 50GBASE-ER (50GE)\n* `50gbase-fr` - 50GBASE-FR (50GE)\n* `50gbase-lr` - 50GBASE-LR (50GE)\n* `50gbase-sr` - 50GBASE-SR (50GE)\n* `100gbase-cr1` - 100GBASE-CR1 (100GE DAC)\n* `100gbase-cr2` - 100GBASE-CR2 (100GE DAC)\n* `100gbase-cr4` - 100GBASE-CR4 (100GE DAC)\n* `100gbase-cr10` - 100GBASE-CR10 (100GE DAC)\n* `100gbase-cwdm4` - 100GBASE-CWDM4 (100GE)\n* `100gbase-dr` - 100GBASE-DR (100GE)\n* `100gbase-er4` - 100GBASE-ER4 (100GE)\n* `100gbase-fr1` - 100GBASE-FR1 (100GE)\n* `100gbase-lr1` - 100GBASE-LR1 (100GE)\n* `100gbase-lr4` - 100GBASE-LR4 (100GE)\n* `100gbase-sr1` - 100GBASE-SR1 (100GE)\n* `100gbase-sr1.2` - 100GBASE-SR1.2 (100GE BiDi)\n* `100gbase-sr2` - 100GBASE-SR2 (100GE)\n* `100gbase-sr4` - 100GBASE-SR4 (100GE)\n* `100gbase-sr10` - 100GBASE-SR10 (100GE)\n* `100gbase-zr` - 100GBASE-ZR (100GE)\n* `200gbase-cr2` - 200GBASE-CR2 (200GE)\n* `200gbase-cr4` - 200GBASE-CR4 (200GE)\n* `200gbase-dr4` - 200GBASE-DR4 (200GE)\n* `200gbase-er4` - 200GBASE-ER4 (200GE)\n* `200gbase-fr4` - 200GBASE-FR4 (200GE)\n* `200gbase-lr4` - 200GBASE-LR4 (200GE)\n* `200gbase-sr2` - 200GBASE-SR2 (200GE)\n* `200gbase-sr4` - 200GBASE-SR4 (200GE)\n* `200gbase-vr2` - 200GBASE-VR2 (200GE)\n* `400gbase-cr4` - 400GBASE-CR4 (400GE)\n* `400gbase-dr4` - 400GBASE-DR4 (400GE)\n* `400gbase-er8` - 400GBASE-ER8 (400GE)\n* `400gbase-fr4` - 400GBASE-FR4 (400GE)\n* `400gbase-fr8` - 400GBASE-FR8 (400GE)\n* `400gbase-lr4` - 400GBASE-LR4 (400GE)\n* `400gbase-lr8` - 400GBASE-LR8 (400GE)\n* `400gbase-sr4` - 400GBASE-SR4 (400GE)\n* `400gbase-sr4_2` - 400GBASE-SR4.2 (400GE BiDi)\n* `400gbase-sr8` - 400GBASE-SR8 (400GE)\n* `400gbase-sr16` - 400GBASE-SR16 (400GE)\n* `400gbase-vr4` - 400GBASE-VR4 (400GE)\n* `400gbase-zr` - 400GBASE-ZR (400GE)\n* `800gbase-cr8` - 800GBASE-CR8 (800GE)\n* `800gbase-dr8` - 800GBASE-DR8 (800GE)\n* `800gbase-sr8` - 800GBASE-SR8 (800GE)\n* `800gbase-vr8` - 800GBASE-VR8 (800GE)\n* `1.6tbase-cr8` - 1.6TBASE-CR8 (1.6TE)\n* `1.6tbase-dr8` - 1.6TBASE-DR8 (1.6TE)\n* `1.6tbase-dr8-2` - 1.6TBASE-DR8-2 (1.6TE)\n* `100base-x-sfp` - SFP (100ME)\n* `1000base-x-gbic` - GBIC (1GE)\n* `1000base-x-sfp` - SFP (1GE)\n* `2.5gbase-x-sfp` - SFP (2.5GE)\n* `10gbase-x-sfpp` - SFP+ (10GE)\n* `10gbase-x-xenpak` - XENPAK (10GE)\n* `10gbase-x-xfp` - XFP (10GE)\n* `10gbase-x-x2` - X2 (10GE)\n* `25gbase-x-sfp28` - SFP28 (25GE)\n* `40gbase-x-qsfpp` - QSFP+ (40GE)\n* `50gbase-x-sfp28` - QSFP28 (50GE)\n* `50gbase-x-sfp56` - SFP56 (50GE)\n* `100gbase-x-cfp` - CFP (100GE)\n* `100gbase-x-cfp2` - CFP2 (100GE)\n* `100gbase-x-cfp4` - CFP4 (100GE)\n* `100gbase-x-cxp` - CXP (100GE)\n* `100gbase-x-cpak` - Cisco CPAK (100GE)\n* `100gbase-x-dsfp` - DSFP (100GE)\n* `100gbase-x-qsfp28` - QSFP28 (100GE)\n* `100gbase-x-qsfpdd` - QSFP-DD (100GE)\n* `100gbase-x-sfp112` - SFP112 (100GE)\n* `100gbase-x-sfpdd` - SFP-DD (100GE)\n* `200gbase-x-cfp2` - CFP2 (200GE)\n* `200gbase-x-qsfp56` - QSFP56 (200GE)\n* `200gbase-x-qsfpdd` - QSFP-DD (200GE)\n* `400gbase-x-qsfp112` - QSFP112 (400GE)\n* `400gbase-x-qsfpdd` - QSFP-DD (400GE)\n* `400gbase-x-cdfp` - CDFP (400GE)\n* `400gbase-x-cfp2` - CFP2 (400GE)\n* `400gbase-x-cfp8` - CPF8 (400GE)\n* `400gbase-x-osfp` - OSFP (400GE)\n* `400gbase-x-osfp-rhs` - OSFP-RHS (400GE)\n* `800gbase-x-osfp` - OSFP (800GE)\n* `800gbase-x-qsfpdd` - QSFP-DD (800GE)\n* `1.6tbase-x-osfp1600` - OSFP1600 (1.6TE)\n* `1.6tbase-x-osfp1600-rhs` - OSFP1600-RHS (1.6TE)\n* `1.6tbase-x-qsfpdd1600` - QSFP-DD1600 (1.6TE)\n* `1000base-kx` - 1000BASE-KX (1GE)\n* `2.5gbase-kx` - 2.5GBASE-KX (2.5GE)\n* `5gbase-kr` - 5GBASE-KR (5GE)\n* `10gbase-kr` - 10GBASE-KR (10GE)\n* `10gbase-kx4` - 10GBASE-KX4 (10GE)\n* `25gbase-kr` - 25GBASE-KR (25GE)\n* `40gbase-kr4` - 40GBASE-KR4 (40GE)\n* `50gbase-kr` - 50GBASE-KR (50GE)\n* `100gbase-kp4` - 100GBASE-KP4 (100GE)\n* `100gbase-kr2` - 100GBASE-KR2 (100GE)\n* `100gbase-kr4` - 100GBASE-KR4 (100GE)\n* `1.6tbase-kr8` - 1.6TBASE-KR8 (1.6TE)\n* `ieee802.11a` - IEEE 802.11a\n* `ieee802.11g` - IEEE 802.11b/g\n* `ieee802.11n` - IEEE 802.11n (Wi-Fi 4)\n* `ieee802.11ac` - IEEE 802.11ac (Wi-Fi 5)\n* `ieee802.11ad` - IEEE 802.11ad (WiGig)\n* `ieee802.11ax` - IEEE 802.11ax (Wi-Fi 6)\n* `ieee802.11ay` - IEEE 802.11ay (WiGig)\n* `ieee802.11be` - IEEE 802.11be (Wi-Fi 7)\n* `ieee802.15.1` - IEEE 802.15.1 (Bluetooth)\n* `ieee802.15.4` - IEEE 802.15.4 (LR-WPAN)\n* `other-wireless` - Other (Wireless)\n* `gsm` - GSM\n* `cdma` - CDMA\n* `lte` - LTE\n* `4g` - 4G\n* `5g` - 5G\n* `sonet-oc3` - OC-3/STM-1\n* `sonet-oc12` - OC-12/STM-4\n* `sonet-oc48` - OC-48/STM-16\n* `sonet-oc192` - OC-192/STM-64\n* `sonet-oc768` - OC-768/STM-256\n* `sonet-oc1920` - OC-1920/STM-640\n* `sonet-oc3840` - OC-3840/STM-1234\n* `1gfc-sfp` - SFP (1GFC)\n* `2gfc-sfp` - SFP (2GFC)\n* `4gfc-sfp` - SFP (4GFC)\n* `8gfc-sfpp` - SFP+ (8GFC)\n* `16gfc-sfpp` - SFP+ (16GFC)\n* `32gfc-sfp28` - SFP28 (32GFC)\n* `32gfc-sfpp` - SFP+ (32GFC)\n* `64gfc-qsfpp` - QSFP+ (64GFC)\n* `64gfc-sfpdd` - SFP-DD (64GFC)\n* `64gfc-sfpp` - SFP+ (64GFC)\n* `128gfc-qsfp28` - QSFP28 (128GFC)\n* `infiniband-sdr` - SDR (2 Gbps)\n* `infiniband-ddr` - DDR (4 Gbps)\n* `infiniband-qdr` - QDR (8 Gbps)\n* `infiniband-fdr10` - FDR10 (10 Gbps)\n* `infiniband-fdr` - FDR (13.5 Gbps)\n* `infiniband-edr` - EDR (25 Gbps)\n* `infiniband-hdr` - HDR (50 Gbps)\n* `infiniband-ndr` - NDR (100 Gbps)\n* `infiniband-xdr` - XDR (200 Gbps)\n* `infiniband-sdr-4x` - SDR 4X (8 Gbps)\n* `infiniband-ddr-4x` - DDR 4X (16 Gbps)\n* `infiniband-qdr-4x` - QDR 4X (32 Gbps)\n* `infiniband-fdr10-4x` - FDR10 4X (40 Gbps)\n* `infiniband-fdr-4x` - FDR 4X (56 Gbps)\n* `infiniband-edr-4x` - EDR 4X (100 Gbps)\n* `infiniband-hdr-4x` - HDR 4X (200 Gbps)\n* `infiniband-ndr-4x` - NDR 4X (400 Gbps)\n* `infiniband-xdr-4x` - XDR 4X (800 Gbps)\n* `t1` - T1 (1.544 Mbps)\n* `e1` - E1 (2.048 Mbps)\n* `t3` - T3 (45 Mbps)\n* `e3` - E3 (34 Mbps)\n* `xdsl` - xDSL\n* `docsis` - DOCSIS\n* `moca` - MoCA\n* `bpon` - BPON (622 Mbps / 155 Mbps)\n* `epon` - EPON (1 Gbps)\n* `10g-epon` - 10G-EPON (10 Gbps)\n* `gpon` - GPON (2.5 Gbps / 1.25 Gbps)\n* `xg-pon` - XG-PON (10 Gbps / 2.5 Gbps)\n* `xgs-pon` - XGS-PON (10 Gbps)\n* `ng-pon2` - NG-PON2 (TWDM-PON) (4x10 Gbps)\n* `25g-pon` - 25G-PON (25 Gbps)\n* `50g-pon` - 50G-PON (50 Gbps)\n* `cisco-stackwise` - Cisco StackWise\n* `cisco-stackwise-plus` - Cisco StackWise Plus\n* `cisco-flexstack` - Cisco FlexStack\n* `cisco-flexstack-plus` - Cisco FlexStack Plus\n* `cisco-stackwise-80` - Cisco StackWise-80\n* `cisco-stackwise-160` - Cisco StackWise-160\n* `cisco-stackwise-320` - Cisco StackWise-320\n* `cisco-stackwise-480` - Cisco StackWise-480\n* `cisco-stackwise-1t` - Cisco StackWise-1T\n* `juniper-vcp` - Juniper VCP\n* `extreme-summitstack` - Extreme SummitStack\n* `extreme-summitstack-128` - Extreme SummitStack-128\n* `extreme-summitstack-256` - Extreme SummitStack-256\n* `extreme-summitstack-512` - Extreme SummitStack-512\n* `hpe-synergy-interconnect-link` - HPE Synergy Interconnect Link\n* `other` - Other", - "x-spec-enum-id": "b0c97040e5abdff1" + "description": "* `virtual` - Virtual\n* `bridge` - Bridge\n* `lag` - Link Aggregation Group (LAG)\n* `channel` - Channel\n* `100base-fx` - 100BASE-FX (10/100ME)\n* `100base-lfx` - 100BASE-LFX (10/100ME)\n* `100base-tx` - 100BASE-TX (10/100ME)\n* `100base-t1` - 100BASE-T1 (10/100ME)\n* `1000base-bx10-d` - 1000BASE-BX10-D (1GE BiDi Down)\n* `1000base-bx10-u` - 1000BASE-BX10-U (1GE BiDi Up)\n* `1000base-cwdm` - 1000BASE-CWDM (1GE)\n* `1000base-cx` - 1000BASE-CX (1GE DAC)\n* `1000base-dwdm` - 1000BASE-DWDM (1GE)\n* `1000base-ex` - 1000BASE-EX (1GE)\n* `1000base-lsx` - 1000BASE-LSX (1GE)\n* `1000base-lx` - 1000BASE-LX (1GE)\n* `1000base-lx10` - 1000BASE-LX10/LH (1GE)\n* `1000base-sx` - 1000BASE-SX (1GE)\n* `1000base-t` - 1000BASE-T (1GE)\n* `1000base-tx` - 1000BASE-TX (1GE)\n* `1000base-zx` - 1000BASE-ZX (1GE)\n* `2.5gbase-t` - 2.5GBASE-T (2.5GE)\n* `5gbase-t` - 5GBASE-T (5GE)\n* `10gbase-br-d` - 10GBASE-BR-D (10GE BiDi Down)\n* `10gbase-br-u` - 10GBASE-BR-U (10GE BiDi Up)\n* `10gbase-cu` - 10GBASE-CU (10GE DAC Passive Twinax)\n* `10gbase-cx4` - 10GBASE-CX4 (10GE DAC)\n* `10gbase-er` - 10GBASE-ER (10GE)\n* `10gbase-lr` - 10GBASE-LR (10GE)\n* `10gbase-lrm` - 10GBASE-LRM (10GE)\n* `10gbase-lx4` - 10GBASE-LX4 (10GE)\n* `10gbase-sr` - 10GBASE-SR (10GE)\n* `10gbase-t` - 10GBASE-T (10GE)\n* `10gbase-zr` - 10GBASE-ZR (10GE)\n* `25gbase-cr` - 25GBASE-CR (25GE DAC)\n* `25gbase-er` - 25GBASE-ER (25GE)\n* `25gbase-lr` - 25GBASE-LR (25GE)\n* `25gbase-sr` - 25GBASE-SR (25GE)\n* `25gbase-t` - 25GBASE-T (25GE)\n* `40gbase-cr4` - 40GBASE-CR4 (40GE DAC)\n* `40gbase-er4` - 40GBASE-ER4 (40GE)\n* `40gbase-fr4` - 40GBASE-FR4 (40GE)\n* `40gbase-lr4` - 40GBASE-LR4 (40GE)\n* `40gbase-sr4` - 40GBASE-SR4 (40GE)\n* `40gbase-sr4-bd` - 40GBASE-SR4 (40GE BiDi)\n* `50gbase-cr` - 50GBASE-CR (50GE DAC)\n* `50gbase-er` - 50GBASE-ER (50GE)\n* `50gbase-fr` - 50GBASE-FR (50GE)\n* `50gbase-lr` - 50GBASE-LR (50GE)\n* `50gbase-sr` - 50GBASE-SR (50GE)\n* `100gbase-cr1` - 100GBASE-CR1 (100GE DAC)\n* `100gbase-cr2` - 100GBASE-CR2 (100GE DAC)\n* `100gbase-cr4` - 100GBASE-CR4 (100GE DAC)\n* `100gbase-cr10` - 100GBASE-CR10 (100GE DAC)\n* `100gbase-cwdm4` - 100GBASE-CWDM4 (100GE)\n* `100gbase-dr` - 100GBASE-DR (100GE)\n* `100gbase-er4` - 100GBASE-ER4 (100GE)\n* `100gbase-fr1` - 100GBASE-FR1 (100GE)\n* `100gbase-lr1` - 100GBASE-LR1 (100GE)\n* `100gbase-lr4` - 100GBASE-LR4 (100GE)\n* `100gbase-sr1` - 100GBASE-SR1 (100GE)\n* `100gbase-sr1.2` - 100GBASE-SR1.2 (100GE BiDi)\n* `100gbase-sr2` - 100GBASE-SR2 (100GE)\n* `100gbase-sr4` - 100GBASE-SR4 (100GE)\n* `100gbase-sr10` - 100GBASE-SR10 (100GE)\n* `100gbase-zr` - 100GBASE-ZR (100GE)\n* `200gbase-cr2` - 200GBASE-CR2 (200GE)\n* `200gbase-cr4` - 200GBASE-CR4 (200GE)\n* `200gbase-dr4` - 200GBASE-DR4 (200GE)\n* `200gbase-er4` - 200GBASE-ER4 (200GE)\n* `200gbase-fr4` - 200GBASE-FR4 (200GE)\n* `200gbase-lr4` - 200GBASE-LR4 (200GE)\n* `200gbase-sr2` - 200GBASE-SR2 (200GE)\n* `200gbase-sr4` - 200GBASE-SR4 (200GE)\n* `200gbase-vr2` - 200GBASE-VR2 (200GE)\n* `400gbase-cr4` - 400GBASE-CR4 (400GE)\n* `400gbase-dr4` - 400GBASE-DR4 (400GE)\n* `400gbase-er8` - 400GBASE-ER8 (400GE)\n* `400gbase-fr4` - 400GBASE-FR4 (400GE)\n* `400gbase-fr8` - 400GBASE-FR8 (400GE)\n* `400gbase-lr4` - 400GBASE-LR4 (400GE)\n* `400gbase-lr8` - 400GBASE-LR8 (400GE)\n* `400gbase-sr4` - 400GBASE-SR4 (400GE)\n* `400gbase-sr4_2` - 400GBASE-SR4.2 (400GE BiDi)\n* `400gbase-sr8` - 400GBASE-SR8 (400GE)\n* `400gbase-sr16` - 400GBASE-SR16 (400GE)\n* `400gbase-vr4` - 400GBASE-VR4 (400GE)\n* `400gbase-zr` - 400GBASE-ZR (400GE)\n* `800gbase-cr8` - 800GBASE-CR8 (800GE)\n* `800gbase-dr8` - 800GBASE-DR8 (800GE)\n* `800gbase-sr8` - 800GBASE-SR8 (800GE)\n* `800gbase-vr8` - 800GBASE-VR8 (800GE)\n* `1.6tbase-cr8` - 1.6TBASE-CR8 (1.6TE)\n* `1.6tbase-dr8` - 1.6TBASE-DR8 (1.6TE)\n* `1.6tbase-dr8-2` - 1.6TBASE-DR8-2 (1.6TE)\n* `100base-x-sfp` - SFP (100ME)\n* `1000base-x-gbic` - GBIC (1GE)\n* `1000base-x-sfp` - SFP (1GE)\n* `2.5gbase-x-sfp` - SFP (2.5GE)\n* `10gbase-x-sfpp` - SFP+ (10GE)\n* `10gbase-x-xenpak` - XENPAK (10GE)\n* `10gbase-x-xfp` - XFP (10GE)\n* `10gbase-x-x2` - X2 (10GE)\n* `25gbase-x-sfp28` - SFP28 (25GE)\n* `40gbase-x-qsfpp` - QSFP+ (40GE)\n* `50gbase-x-sfp28` - QSFP28 (50GE)\n* `50gbase-x-sfp56` - SFP56 (50GE)\n* `100gbase-x-cfp` - CFP (100GE)\n* `100gbase-x-cfp2` - CFP2 (100GE)\n* `100gbase-x-cfp4` - CFP4 (100GE)\n* `100gbase-x-cxp` - CXP (100GE)\n* `100gbase-x-cpak` - Cisco CPAK (100GE)\n* `100gbase-x-dsfp` - DSFP (100GE)\n* `100gbase-x-qsfp28` - QSFP28 (100GE)\n* `100gbase-x-qsfpdd` - QSFP-DD (100GE)\n* `100gbase-x-sfp112` - SFP112 (100GE)\n* `100gbase-x-sfpdd` - SFP-DD (100GE)\n* `200gbase-x-cfp2` - CFP2 (200GE)\n* `200gbase-x-qsfp56` - QSFP56 (200GE)\n* `200gbase-x-qsfpdd` - QSFP-DD (200GE)\n* `400gbase-x-qsfp112` - QSFP112 (400GE)\n* `400gbase-x-qsfpdd` - QSFP-DD (400GE)\n* `400gbase-x-cdfp` - CDFP (400GE)\n* `400gbase-x-cfp2` - CFP2 (400GE)\n* `400gbase-x-cfp8` - CPF8 (400GE)\n* `400gbase-x-osfp` - OSFP (400GE)\n* `400gbase-x-osfp-rhs` - OSFP-RHS (400GE)\n* `800gbase-x-osfp` - OSFP (800GE)\n* `800gbase-x-qsfpdd` - QSFP-DD (800GE)\n* `1.6tbase-x-osfp1600` - OSFP1600 (1.6TE)\n* `1.6tbase-x-osfp1600-rhs` - OSFP1600-RHS (1.6TE)\n* `1.6tbase-x-qsfpdd1600` - QSFP-DD1600 (1.6TE)\n* `1000base-kx` - 1000BASE-KX (1GE)\n* `2.5gbase-kx` - 2.5GBASE-KX (2.5GE)\n* `5gbase-kr` - 5GBASE-KR (5GE)\n* `10gbase-kr` - 10GBASE-KR (10GE)\n* `10gbase-kx4` - 10GBASE-KX4 (10GE)\n* `25gbase-kr` - 25GBASE-KR (25GE)\n* `40gbase-kr4` - 40GBASE-KR4 (40GE)\n* `50gbase-kr` - 50GBASE-KR (50GE)\n* `100gbase-kp4` - 100GBASE-KP4 (100GE)\n* `100gbase-kr2` - 100GBASE-KR2 (100GE)\n* `100gbase-kr4` - 100GBASE-KR4 (100GE)\n* `1.6tbase-kr8` - 1.6TBASE-KR8 (1.6TE)\n* `ieee802.11a` - IEEE 802.11a\n* `ieee802.11g` - IEEE 802.11b/g\n* `ieee802.11n` - IEEE 802.11n (Wi-Fi 4)\n* `ieee802.11ac` - IEEE 802.11ac (Wi-Fi 5)\n* `ieee802.11ad` - IEEE 802.11ad (WiGig)\n* `ieee802.11ax` - IEEE 802.11ax (Wi-Fi 6)\n* `ieee802.11ay` - IEEE 802.11ay (WiGig)\n* `ieee802.11be` - IEEE 802.11be (Wi-Fi 7)\n* `ieee802.15.1` - IEEE 802.15.1 (Bluetooth)\n* `ieee802.15.4` - IEEE 802.15.4 (LR-WPAN)\n* `other-wireless` - Other (Wireless)\n* `gsm` - GSM\n* `cdma` - CDMA\n* `lte` - LTE\n* `4g` - 4G\n* `5g` - 5G\n* `sonet-oc3` - OC-3/STM-1\n* `sonet-oc12` - OC-12/STM-4\n* `sonet-oc48` - OC-48/STM-16\n* `sonet-oc192` - OC-192/STM-64\n* `sonet-oc768` - OC-768/STM-256\n* `sonet-oc1920` - OC-1920/STM-640\n* `sonet-oc3840` - OC-3840/STM-1234\n* `1gfc-sfp` - SFP (1GFC)\n* `2gfc-sfp` - SFP (2GFC)\n* `4gfc-sfp` - SFP (4GFC)\n* `8gfc-sfpp` - SFP+ (8GFC)\n* `16gfc-sfpp` - SFP+ (16GFC)\n* `32gfc-sfp28` - SFP28 (32GFC)\n* `32gfc-sfpp` - SFP+ (32GFC)\n* `64gfc-qsfpp` - QSFP+ (64GFC)\n* `64gfc-sfpdd` - SFP-DD (64GFC)\n* `64gfc-sfpp` - SFP+ (64GFC)\n* `128gfc-qsfp28` - QSFP28 (128GFC)\n* `infiniband-sdr` - SDR (2 Gbps)\n* `infiniband-ddr` - DDR (4 Gbps)\n* `infiniband-qdr` - QDR (8 Gbps)\n* `infiniband-fdr10` - FDR10 (10 Gbps)\n* `infiniband-fdr` - FDR (13.5 Gbps)\n* `infiniband-edr` - EDR (25 Gbps)\n* `infiniband-hdr` - HDR (50 Gbps)\n* `infiniband-ndr` - NDR (100 Gbps)\n* `infiniband-xdr` - XDR (200 Gbps)\n* `infiniband-sdr-4x` - SDR 4X (8 Gbps)\n* `infiniband-ddr-4x` - DDR 4X (16 Gbps)\n* `infiniband-qdr-4x` - QDR 4X (32 Gbps)\n* `infiniband-fdr10-4x` - FDR10 4X (40 Gbps)\n* `infiniband-fdr-4x` - FDR 4X (56 Gbps)\n* `infiniband-edr-4x` - EDR 4X (100 Gbps)\n* `infiniband-hdr-4x` - HDR 4X (200 Gbps)\n* `infiniband-ndr-4x` - NDR 4X (400 Gbps)\n* `infiniband-xdr-4x` - XDR 4X (800 Gbps)\n* `t1` - T1 (1.544 Mbps)\n* `e1` - E1 (2.048 Mbps)\n* `t3` - T3 (45 Mbps)\n* `e3` - E3 (34 Mbps)\n* `xdsl` - xDSL\n* `docsis` - DOCSIS\n* `moca` - MoCA\n* `bpon` - BPON (622 Mbps / 155 Mbps)\n* `epon` - EPON (1 Gbps)\n* `10g-epon` - 10G-EPON (10 Gbps)\n* `gpon` - GPON (2.5 Gbps / 1.25 Gbps)\n* `xg-pon` - XG-PON (10 Gbps / 2.5 Gbps)\n* `xgs-pon` - XGS-PON (10 Gbps)\n* `ng-pon2` - NG-PON2 (TWDM-PON) (4x10 Gbps)\n* `25g-pon` - 25G-PON (25 Gbps)\n* `50g-pon` - 50G-PON (50 Gbps)\n* `cisco-stackwise` - Cisco StackWise\n* `cisco-stackwise-plus` - Cisco StackWise Plus\n* `cisco-flexstack` - Cisco FlexStack\n* `cisco-flexstack-plus` - Cisco FlexStack Plus\n* `cisco-stackwise-80` - Cisco StackWise-80\n* `cisco-stackwise-160` - Cisco StackWise-160\n* `cisco-stackwise-320` - Cisco StackWise-320\n* `cisco-stackwise-480` - Cisco StackWise-480\n* `cisco-stackwise-1t` - Cisco StackWise-1T\n* `juniper-vcp` - Juniper VCP\n* `extreme-summitstack` - Extreme SummitStack\n* `extreme-summitstack-128` - Extreme SummitStack-128\n* `extreme-summitstack-256` - Extreme SummitStack-256\n* `extreme-summitstack-512` - Extreme SummitStack-512\n* `hpe-synergy-interconnect-link` - HPE Synergy Interconnect Link\n* `other` - Other", + "x-spec-enum-id": "19cc901fcea417ad" + }, + "channels": { + "type": "integer", + "maximum": 1024, + "minimum": 1, + "nullable": true, + "description": "The number of channels into which this interface is channelized" + }, + "channel_id": { + "type": "integer", + "maximum": 1024, + "minimum": 1, + "nullable": true, + "description": "The channel on the parent interface to which this subinterface is bound" }, "enabled": { "type": "boolean" @@ -250914,6 +282413,14 @@ "type": "string", "maxLength": 200 }, + "parent": { + "allOf": [ + { + "$ref": "#/components/schemas/NestedInterfaceTemplateRequest" + } + ], + "nullable": true + }, "bridge": { "allOf": [ { @@ -251775,6 +283282,12 @@ "type": "string", "maxLength": 200 }, + "module_bay_types": { + "type": "array", + "items": { + "type": "integer" + } + }, "installed_module": { "oneOf": [ { @@ -251885,6 +283398,12 @@ "description": { "type": "string", "maxLength": 200 + }, + "module_bay_types": { + "type": "array", + "items": { + "type": "integer" + } } }, "required": [ @@ -251892,6 +283411,93 @@ "name" ] }, + "BulkModuleBayTypeRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "manufacturer": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefManufacturerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "color": { + "oneOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{6}$", + "maxLength": 6 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "comments": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "id", + "name", + "slug" + ] + }, "BulkModuleRequest": { "type": "object", "description": "Base serializer class for models inheriting from PrimaryModel.", @@ -252112,6 +283718,20 @@ "x-spec-enum-id": "5ad4e700c656b09d", "nullable": true }, + "cooling_method": { + "enum": [ + "air", + "liquid", + "hybrid", + "immersion", + "", + null + ], + "type": "string", + "description": "* `air` - Air\n* `liquid` - Liquid\n* `hybrid` - Hybrid\n* `immersion` - Immersion", + "x-spec-enum-id": "fff5375d415a4fdc", + "nullable": true + }, "weight": { "type": "number", "format": "double", @@ -252135,6 +283755,12 @@ "x-spec-enum-id": "2235ce3f404afbc0", "nullable": true }, + "end_of_life": { + "type": "string", + "format": "date", + "nullable": true, + "description": "The date after which this module type is no longer supported by the manufacturer" + }, "description": { "type": "string", "maxLength": 200 @@ -252142,6 +283768,12 @@ "attributes": { "nullable": true }, + "module_bay_types": { + "type": "array", + "items": { + "type": "integer" + } + }, "owner": { "oneOf": [ { @@ -252325,6 +283957,48 @@ "object_types" ] }, + "BulkOperationEntryError": { + "type": "object", + "description": "The failure of a single object within a bulk operation.", + "properties": { + "id": { + "type": "integer", + "description": "The ID of the object which failed. Present once the entry has been matched to an object; mutually exclusive with `index`." + }, + "index": { + "type": "integer", + "description": "The zero-based position of the entry within the submitted list. Used where no object has been identified for the entry: always for creations, and for updates and deletions where the entry itself could not be interpreted (e.g. a missing or non-numeric `id`). Mutually exclusive with `id`." + }, + "errors": { + "type": "object", + "additionalProperties": {}, + "description": "The errors for this entry, keyed by field name. Values are ordinarily arrays of messages. Errors which pertain to no particular field -- model validation, protection rules, restricted tags, object-level permissions, or the shape of the entry itself -- all appear under the single key `__all__`." + } + }, + "required": [ + "errors" + ] + }, + "BulkOperationError": { + "type": "object", + "description": "The body returned when a bulk operation fails, correlating each failure with the object\nresponsible for it.", + "properties": { + "detail": { + "type": "string", + "description": "A summary of the failure, e.g. \"1 of 3 objects could not be updated.\"" + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BulkOperationEntryError" + }, + "description": "One entry per object which failed; objects which would have succeeded are omitted, as a bulk operation is all-or-none. Absent where the request could not be attributed to individual entries at all (e.g. a request body which is not a list)." + } + }, + "required": [ + "detail" + ] + }, "BulkOwnerGroupRequest": { "type": "object", "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)", @@ -254312,6 +285986,28 @@ "description": "* `front-to-rear` - Front to rear\n* `rear-to-front` - Rear to front", "x-spec-enum-id": "a784734d07ef1b3c" }, + "cooling_capability": { + "enum": [ + "air-only", + "hybrid", + "liquid-only", + "", + null + ], + "type": "string", + "description": "* `air-only` - Air only\n* `hybrid` - Hybrid\n* `liquid-only` - Liquid only", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + }, + "cooling_capacity": { + "type": "number", + "format": "double", + "maximum": 100000000, + "minimum": 0, + "exclusiveMaximum": true, + "nullable": true, + "description": "Cooling capacity (kW)" + }, "description": { "type": "string", "maxLength": 200 @@ -254668,6 +286364,28 @@ "nullable": true, "description": "Maximum depth of a mounted device, in millimeters. For four-post racks, this is the distance between the front and rear rails." }, + "cooling_capability": { + "enum": [ + "air-only", + "hybrid", + "liquid-only", + "", + null + ], + "type": "string", + "description": "* `air-only` - Air only\n* `hybrid` - Hybrid\n* `liquid-only` - Liquid only", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + }, + "cooling_capacity": { + "type": "number", + "format": "double", + "maximum": 100000000, + "minimum": 0, + "exclusiveMaximum": true, + "nullable": true, + "description": "Cooling capacity (kW)" + }, "owner": { "oneOf": [ { @@ -255307,7 +287025,7 @@ }, "BulkServiceRequest": { "type": "object", - "description": "Base serializer class for models inheriting from PrimaryModel.", + "description": "Shared port-mapping handling for the Service and ServiceTemplate serializers, including backward\ncompatibility for the legacy single-protocol ``protocol``/``ports`` representation.\n\nRead: alongside the ``port_mappings`` list, a service that uses a single protocol also reports the\nlegacy ``protocol`` and ``ports`` fields; a multi-protocol service reports ``null`` for both (it\ncannot be expressed in the old single-protocol format).\n\nWrite: either format is accepted. When the legacy ``protocol``/``ports`` pair is supplied (and\n``port_mappings`` is not), it is translated into ``port_mappings``. Supplying both together is\naccepted only when the legacy fields agree with what ``port_mappings`` implies (e.g. a full-object\nround-trip that echoes back the read representation); a genuine conflict is rejected as ambiguous.\n\nSubclassing ``serializers.Serializer`` (rather than a plain mixin) lets DRF's metaclass collect the\nfields declared here into the inheriting serializers.", "properties": { "id": { "type": "integer" @@ -255326,15 +287044,24 @@ "minLength": 1, "maxLength": 100 }, + "port_mappings": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "protocol": { "enum": [ "tcp", "udp", - "sctp" + "sctp", + null ], "type": "string", - "description": "* `tcp` - TCP\n* `udp` - UDP\n* `sctp` - SCTP", - "x-spec-enum-id": "e4b15bec749a2a32" + "description": "Deprecated; use port_mappings. Reported only for single-protocol services.", + "x-spec-enum-id": "e4b15bec749a2a32", + "nullable": true }, "ports": { "type": "array", @@ -255343,7 +287070,8 @@ "maximum": 65535, "minimum": 1 }, - "title": "Port numbers" + "nullable": true, + "description": "Deprecated; use port_mappings. Reported only for single-protocol services." }, "ipaddresses": { "type": "array", @@ -255389,13 +287117,12 @@ "id", "name", "parent_object_id", - "parent_object_type", - "ports" + "parent_object_type" ] }, "BulkServiceTemplateRequest": { "type": "object", - "description": "Base serializer class for models inheriting from PrimaryModel.", + "description": "Shared port-mapping handling for the Service and ServiceTemplate serializers, including backward\ncompatibility for the legacy single-protocol ``protocol``/``ports`` representation.\n\nRead: alongside the ``port_mappings`` list, a service that uses a single protocol also reports the\nlegacy ``protocol`` and ``ports`` fields; a multi-protocol service reports ``null`` for both (it\ncannot be expressed in the old single-protocol format).\n\nWrite: either format is accepted. When the legacy ``protocol``/``ports`` pair is supplied (and\n``port_mappings`` is not), it is translated into ``port_mappings``. Supplying both together is\naccepted only when the legacy fields agree with what ``port_mappings`` implies (e.g. a full-object\nround-trip that echoes back the read representation); a genuine conflict is rejected as ambiguous.\n\nSubclassing ``serializers.Serializer`` (rather than a plain mixin) lets DRF's metaclass collect the\nfields declared here into the inheriting serializers.", "properties": { "id": { "type": "integer" @@ -255405,15 +287132,24 @@ "minLength": 1, "maxLength": 100 }, + "port_mappings": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "protocol": { "enum": [ "tcp", "udp", - "sctp" + "sctp", + null ], "type": "string", - "description": "* `tcp` - TCP\n* `udp` - UDP\n* `sctp` - SCTP", - "x-spec-enum-id": "e4b15bec749a2a32" + "description": "Deprecated; use port_mappings. Reported only for single-protocol services.", + "x-spec-enum-id": "e4b15bec749a2a32", + "nullable": true }, "ports": { "type": "array", @@ -255422,7 +287158,8 @@ "maximum": 65535, "minimum": 1 }, - "title": "Port numbers" + "nullable": true, + "description": "Deprecated; use port_mappings. Reported only for single-protocol services." }, "description": { "type": "string", @@ -255460,8 +287197,7 @@ }, "required": [ "id", - "name", - "ports" + "name" ] }, "BulkSiteGroupRequest": { @@ -256032,10 +287768,6 @@ "minimum": 0, "nullable": true, "description": "ID of the cryptographic pepper used to hash the token (v2 only)" - }, - "token": { - "type": "string", - "minLength": 1 } }, "required": [ @@ -256699,7 +288431,7 @@ }, "BulkVMInterfaceRequest": { "type": "object", - "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", + "description": "Mixin for Interface and VMInterface serializers that adds a `mac_address` shortcut field for\ncreating/updating the primary MACAddress in a single request. The validated write is centralized\non the interface model (BaseInterface.set_primary_mac_address); this mixin is a thin adapter that\nowns the permission check, the shortcut-vs-primary_mac_address conflict guard, and translating the\nmodel's validation errors into API errors.", "properties": { "id": { "type": "integer" @@ -256744,6 +288476,11 @@ "minimum": 1, "nullable": true }, + "mac_address": { + "type": "string", + "nullable": true, + "minLength": 1 + }, "primary_mac_address": { "oneOf": [ { @@ -257474,95 +289211,7 @@ "virtual_machine" ] }, - "BulkVirtualMachineTypeRequest": { - "type": "object", - "description": "Base serializer class for models inheriting from PrimaryModel.", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100 - }, - "slug": { - "type": "string", - "minLength": 1, - "maxLength": 100, - "pattern": "^[-a-zA-Z0-9_]+$" - }, - "default_platform": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefPlatformRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "default_vcpus": { - "type": "number", - "format": "double", - "maximum": 10000, - "minimum": 0.01, - "exclusiveMaximum": true, - "nullable": true - }, - "default_memory": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0, - "nullable": true - }, - "description": { - "type": "string", - "maxLength": 200 - }, - "owner": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefOwnerRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "comments": { - "type": "string" - }, - "tags": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NestedTagRequest" - } - }, - "custom_fields": { - "type": "object", - "additionalProperties": {} - } - }, - "required": [ - "id", - "name", - "slug" - ] - }, - "BulkVirtualMachineWithConfigContextRequest": { + "BulkVirtualMachineRequest": { "type": "object", "description": "Base serializer class for models inheriting from PrimaryModel.", "properties": { @@ -257826,6 +289475,94 @@ "name" ] }, + "BulkVirtualMachineTypeRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "default_platform": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefPlatformRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "default_vcpus": { + "type": "number", + "format": "double", + "maximum": 10000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "default_memory": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0, + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "comments": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "id", + "name", + "slug" + ] + }, "BulkWebhookRequest": { "type": "object", "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", @@ -257873,7 +289610,7 @@ }, "body_template": { "type": "string", - "description": "Jinja2 template for a custom request body. If blank, a JSON object representing the change will be included. Available context data includes: event, model, timestamp, username, request_id, and data." + "description": "Jinja2 template for a custom request body. If blank, a JSON object representing the change will be included. Available context data includes: event, model, timestamp, request, and data." }, "secret": { "type": "string", @@ -257890,6 +289627,13 @@ "description": "The specific CA certificate file to use for SSL verification. Leave blank to use the system defaults.", "maxLength": 4096 }, + "timeout": { + "type": "integer", + "maximum": 3600, + "minimum": 1, + "nullable": true, + "description": "The maximum time (in seconds) to wait for a response before failing the request. Leave blank to use the system default (60 seconds)." + }, "custom_fields": { "type": "object", "additionalProperties": {} @@ -263225,6 +294969,1778 @@ "slug" ] }, + "CoolingFeed": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "display_url": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "display": { + "type": "string", + "readOnly": true + }, + "cooling_source": { + "$ref": "#/components/schemas/BriefCoolingSource" + }, + "rack": { + "allOf": [ + { + "$ref": "#/components/schemas/BriefRack" + } + ], + "nullable": true + }, + "name": { + "type": "string", + "maxLength": 100 + }, + "status": { + "type": "object", + "properties": { + "value": { + "enum": [ + "offline", + "active", + "planned", + "failed" + ], + "type": "string", + "description": "* `offline` - Offline\n* `active` - Active\n* `planned` - Planned\n* `failed` - Failed", + "x-spec-enum-id": "ec530572dc778583" + }, + "label": { + "type": "string", + "enum": [ + "Offline", + "Active", + "Planned", + "Failed" + ] + } + } + }, + "cooling_capacity": { + "type": "number", + "format": "double", + "maximum": 100000000, + "minimum": 0, + "exclusiveMaximum": true, + "nullable": true, + "description": "Rated cooling capacity (kW)" + }, + "max_flow": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "max_flow_unit": { + "type": "object", + "properties": { + "value": { + "enum": [ + "lpm", + "m3ph", + "gpm", + "", + null + ], + "type": "string", + "description": "* `lpm` - Liters per minute (L/min)\n* `m3ph` - Cubic meters per hour (m³/h)\n* `gpm` - Gallons per minute (GPM)", + "x-spec-enum-id": "fe1b41d88d34506a" + }, + "label": { + "type": "string", + "enum": [ + "Liters per minute (L/min)", + "Cubic meters per hour (m³/h)", + "Gallons per minute (GPM)" + ] + } + }, + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "tenant": { + "allOf": [ + { + "$ref": "#/components/schemas/BriefTenant" + } + ], + "nullable": true + }, + "owner": { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwner" + } + ], + "nullable": true + }, + "comments": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTag" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + }, + "created": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "last_updated": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + } + }, + "required": [ + "cooling_source", + "created", + "display", + "display_url", + "id", + "last_updated", + "name", + "url" + ] + }, + "CoolingFeedRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "cooling_source": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefCoolingSourceRequest" + } + ] + }, + "rack": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefRackRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "status": { + "enum": [ + "offline", + "active", + "planned", + "failed" + ], + "type": "string", + "description": "* `offline` - Offline\n* `active` - Active\n* `planned` - Planned\n* `failed` - Failed", + "x-spec-enum-id": "ec530572dc778583" + }, + "cooling_capacity": { + "type": "number", + "format": "double", + "maximum": 100000000, + "minimum": 0, + "exclusiveMaximum": true, + "nullable": true, + "description": "Rated cooling capacity (kW)" + }, + "max_flow": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "max_flow_unit": { + "enum": [ + "lpm", + "m3ph", + "gpm", + "", + null + ], + "type": "string", + "description": "* `lpm` - Liters per minute (L/min)\n* `m3ph` - Cubic meters per hour (m³/h)\n* `gpm` - Gallons per minute (GPM)", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "tenant": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefTenantRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "comments": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "cooling_source", + "name" + ] + }, + "CoolingIntake": { + "type": "object", + "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "display_url": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "display": { + "type": "string", + "readOnly": true + }, + "device": { + "$ref": "#/components/schemas/BriefDevice" + }, + "module": { + "allOf": [ + { + "$ref": "#/components/schemas/BriefModule" + } + ], + "nullable": true + }, + "name": { + "type": "string", + "maxLength": 64 + }, + "label": { + "type": "string", + "description": "Physical label", + "maxLength": 64 + }, + "type": { + "type": "object", + "properties": { + "value": { + "enum": [ + "uqd", + "uqdb", + "qdc", + "camlock", + "npt", + "bsp", + "proprietary", + "", + null + ], + "type": "string", + "description": "* `uqd` - UQD (Universal Quick Disconnect)\n* `uqdb` - UQDB (Universal Quick Disconnect, Blind-mate)\n* `qdc` - QDC (Quick Disconnect Coupling)\n* `camlock` - Camlock (cam-and-groove)\n* `npt` - NPT (threaded)\n* `bsp` - BSP (threaded)\n* `proprietary` - Proprietary", + "x-spec-enum-id": "90159a314e98688d" + }, + "label": { + "type": "string", + "enum": [ + "UQD (Universal Quick Disconnect)", + "UQDB (Universal Quick Disconnect, Blind-mate)", + "QDC (Quick Disconnect Coupling)", + "Camlock (cam-and-groove)", + "NPT (threaded)", + "BSP (threaded)", + "Proprietary" + ] + } + }, + "nullable": true + }, + "diameter": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "diameter_unit": { + "type": "object", + "properties": { + "value": { + "enum": [ + "mm", + "cm", + "in", + "", + null + ], + "type": "string", + "description": "* `mm` - Millimeters\n* `cm` - Centimeters\n* `in` - Inches", + "x-spec-enum-id": "2eee9703528be3dd" + }, + "label": { + "type": "string", + "enum": [ + "Millimeters", + "Centimeters", + "Inches" + ] + } + }, + "nullable": true + }, + "max_flow": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "max_flow_unit": { + "type": "object", + "properties": { + "value": { + "enum": [ + "lpm", + "m3ph", + "gpm", + "", + null + ], + "type": "string", + "description": "* `lpm` - Liters per minute (L/min)\n* `m3ph` - Cubic meters per hour (m³/h)\n* `gpm` - Gallons per minute (GPM)", + "x-spec-enum-id": "fe1b41d88d34506a" + }, + "label": { + "type": "string", + "enum": [ + "Liters per minute (L/min)", + "Cubic meters per hour (m³/h)", + "Gallons per minute (GPM)" + ] + } + }, + "nullable": true + }, + "cooling_outflow": { + "allOf": [ + { + "$ref": "#/components/schemas/NestedCoolingOutflow" + } + ], + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwner" + } + ], + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTag" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + }, + "created": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "last_updated": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + } + }, + "required": [ + "created", + "device", + "display", + "display_url", + "id", + "last_updated", + "name", + "url" + ] + }, + "CoolingIntakeRequest": { + "type": "object", + "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", + "properties": { + "device": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefDeviceRequest" + } + ] + }, + "module": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefModuleRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "label": { + "type": "string", + "description": "Physical label", + "maxLength": 64 + }, + "type": { + "enum": [ + "uqd", + "uqdb", + "qdc", + "camlock", + "npt", + "bsp", + "proprietary", + "", + null + ], + "type": "string", + "description": "* `uqd` - UQD (Universal Quick Disconnect)\n* `uqdb` - UQDB (Universal Quick Disconnect, Blind-mate)\n* `qdc` - QDC (Quick Disconnect Coupling)\n* `camlock` - Camlock (cam-and-groove)\n* `npt` - NPT (threaded)\n* `bsp` - BSP (threaded)\n* `proprietary` - Proprietary", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + }, + "diameter": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "diameter_unit": { + "enum": [ + "mm", + "cm", + "in", + "", + null + ], + "type": "string", + "description": "* `mm` - Millimeters\n* `cm` - Centimeters\n* `in` - Inches", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + }, + "max_flow": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "max_flow_unit": { + "enum": [ + "lpm", + "m3ph", + "gpm", + "", + null + ], + "type": "string", + "description": "* `lpm` - Liters per minute (L/min)\n* `m3ph` - Cubic meters per hour (m³/h)\n* `gpm` - Gallons per minute (GPM)", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + }, + "cooling_outflow": { + "allOf": [ + { + "$ref": "#/components/schemas/NestedCoolingOutflowRequest" + } + ], + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "device", + "name" + ] + }, + "CoolingIntakeTemplate": { + "type": "object", + "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "display": { + "type": "string", + "readOnly": true + }, + "device_type": { + "allOf": [ + { + "$ref": "#/components/schemas/BriefDeviceType" + } + ], + "nullable": true + }, + "module_type": { + "allOf": [ + { + "$ref": "#/components/schemas/BriefModuleType" + } + ], + "nullable": true + }, + "name": { + "type": "string", + "description": "{module} is accepted as a substitution for the module bay position when attached to a module type.", + "maxLength": 64 + }, + "label": { + "type": "string", + "description": "Physical label", + "maxLength": 64 + }, + "type": { + "type": "object", + "properties": { + "value": { + "enum": [ + "uqd", + "uqdb", + "qdc", + "camlock", + "npt", + "bsp", + "proprietary", + "", + null + ], + "type": "string", + "description": "* `uqd` - UQD (Universal Quick Disconnect)\n* `uqdb` - UQDB (Universal Quick Disconnect, Blind-mate)\n* `qdc` - QDC (Quick Disconnect Coupling)\n* `camlock` - Camlock (cam-and-groove)\n* `npt` - NPT (threaded)\n* `bsp` - BSP (threaded)\n* `proprietary` - Proprietary", + "x-spec-enum-id": "90159a314e98688d" + }, + "label": { + "type": "string", + "enum": [ + "UQD (Universal Quick Disconnect)", + "UQDB (Universal Quick Disconnect, Blind-mate)", + "QDC (Quick Disconnect Coupling)", + "Camlock (cam-and-groove)", + "NPT (threaded)", + "BSP (threaded)", + "Proprietary" + ] + } + }, + "nullable": true + }, + "diameter": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "diameter_unit": { + "type": "object", + "properties": { + "value": { + "enum": [ + "mm", + "cm", + "in", + "", + null + ], + "type": "string", + "description": "* `mm` - Millimeters\n* `cm` - Centimeters\n* `in` - Inches", + "x-spec-enum-id": "2eee9703528be3dd" + }, + "label": { + "type": "string", + "enum": [ + "Millimeters", + "Centimeters", + "Inches" + ] + } + }, + "nullable": true + }, + "max_flow": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "max_flow_unit": { + "type": "object", + "properties": { + "value": { + "enum": [ + "lpm", + "m3ph", + "gpm", + "", + null + ], + "type": "string", + "description": "* `lpm` - Liters per minute (L/min)\n* `m3ph` - Cubic meters per hour (m³/h)\n* `gpm` - Gallons per minute (GPM)", + "x-spec-enum-id": "fe1b41d88d34506a" + }, + "label": { + "type": "string", + "enum": [ + "Liters per minute (L/min)", + "Cubic meters per hour (m³/h)", + "Gallons per minute (GPM)" + ] + } + }, + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "created": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "last_updated": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + } + }, + "required": [ + "created", + "display", + "id", + "last_updated", + "name", + "url" + ] + }, + "CoolingIntakeTemplateRequest": { + "type": "object", + "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)", + "properties": { + "device_type": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefDeviceTypeRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "module_type": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefModuleTypeRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "description": "{module} is accepted as a substitution for the module bay position when attached to a module type.", + "maxLength": 64 + }, + "label": { + "type": "string", + "description": "Physical label", + "maxLength": 64 + }, + "type": { + "enum": [ + "uqd", + "uqdb", + "qdc", + "camlock", + "npt", + "bsp", + "proprietary", + "", + null + ], + "type": "string", + "description": "* `uqd` - UQD (Universal Quick Disconnect)\n* `uqdb` - UQDB (Universal Quick Disconnect, Blind-mate)\n* `qdc` - QDC (Quick Disconnect Coupling)\n* `camlock` - Camlock (cam-and-groove)\n* `npt` - NPT (threaded)\n* `bsp` - BSP (threaded)\n* `proprietary` - Proprietary", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + }, + "diameter": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "diameter_unit": { + "enum": [ + "mm", + "cm", + "in", + "", + null + ], + "type": "string", + "description": "* `mm` - Millimeters\n* `cm` - Centimeters\n* `in` - Inches", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + }, + "max_flow": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "max_flow_unit": { + "enum": [ + "lpm", + "m3ph", + "gpm", + "", + null + ], + "type": "string", + "description": "* `lpm` - Liters per minute (L/min)\n* `m3ph` - Cubic meters per hour (m³/h)\n* `gpm` - Gallons per minute (GPM)", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + } + }, + "required": [ + "name" + ] + }, + "CoolingOutflow": { + "type": "object", + "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "display_url": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "display": { + "type": "string", + "readOnly": true + }, + "device": { + "$ref": "#/components/schemas/BriefDevice" + }, + "module": { + "allOf": [ + { + "$ref": "#/components/schemas/BriefModule" + } + ], + "nullable": true + }, + "name": { + "type": "string", + "maxLength": 64 + }, + "label": { + "type": "string", + "description": "Physical label", + "maxLength": 64 + }, + "type": { + "type": "object", + "properties": { + "value": { + "enum": [ + "uqd", + "uqdb", + "qdc", + "camlock", + "npt", + "bsp", + "proprietary", + "", + null + ], + "type": "string", + "description": "* `uqd` - UQD (Universal Quick Disconnect)\n* `uqdb` - UQDB (Universal Quick Disconnect, Blind-mate)\n* `qdc` - QDC (Quick Disconnect Coupling)\n* `camlock` - Camlock (cam-and-groove)\n* `npt` - NPT (threaded)\n* `bsp` - BSP (threaded)\n* `proprietary` - Proprietary", + "x-spec-enum-id": "90159a314e98688d" + }, + "label": { + "type": "string", + "enum": [ + "UQD (Universal Quick Disconnect)", + "UQDB (Universal Quick Disconnect, Blind-mate)", + "QDC (Quick Disconnect Coupling)", + "Camlock (cam-and-groove)", + "NPT (threaded)", + "BSP (threaded)", + "Proprietary" + ] + } + }, + "nullable": true + }, + "diameter": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "diameter_unit": { + "type": "object", + "properties": { + "value": { + "enum": [ + "mm", + "cm", + "in", + "", + null + ], + "type": "string", + "description": "* `mm` - Millimeters\n* `cm` - Centimeters\n* `in` - Inches", + "x-spec-enum-id": "2eee9703528be3dd" + }, + "label": { + "type": "string", + "enum": [ + "Millimeters", + "Centimeters", + "Inches" + ] + } + }, + "nullable": true + }, + "cooling_intake": { + "allOf": [ + { + "$ref": "#/components/schemas/BriefCoolingIntake" + } + ], + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwner" + } + ], + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTag" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + }, + "created": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "last_updated": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + } + }, + "required": [ + "created", + "device", + "display", + "display_url", + "id", + "last_updated", + "name", + "url" + ] + }, + "CoolingOutflowRequest": { + "type": "object", + "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", + "properties": { + "device": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefDeviceRequest" + } + ] + }, + "module": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefModuleRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "label": { + "type": "string", + "description": "Physical label", + "maxLength": 64 + }, + "type": { + "enum": [ + "uqd", + "uqdb", + "qdc", + "camlock", + "npt", + "bsp", + "proprietary", + "", + null + ], + "type": "string", + "description": "* `uqd` - UQD (Universal Quick Disconnect)\n* `uqdb` - UQDB (Universal Quick Disconnect, Blind-mate)\n* `qdc` - QDC (Quick Disconnect Coupling)\n* `camlock` - Camlock (cam-and-groove)\n* `npt` - NPT (threaded)\n* `bsp` - BSP (threaded)\n* `proprietary` - Proprietary", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + }, + "diameter": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "diameter_unit": { + "enum": [ + "mm", + "cm", + "in", + "", + null + ], + "type": "string", + "description": "* `mm` - Millimeters\n* `cm` - Centimeters\n* `in` - Inches", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + }, + "cooling_intake": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefCoolingIntakeRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "device", + "name" + ] + }, + "CoolingOutflowTemplate": { + "type": "object", + "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "display": { + "type": "string", + "readOnly": true + }, + "device_type": { + "allOf": [ + { + "$ref": "#/components/schemas/BriefDeviceType" + } + ], + "nullable": true + }, + "module_type": { + "allOf": [ + { + "$ref": "#/components/schemas/BriefModuleType" + } + ], + "nullable": true + }, + "name": { + "type": "string", + "description": "{module} is accepted as a substitution for the module bay position when attached to a module type.", + "maxLength": 64 + }, + "label": { + "type": "string", + "description": "Physical label", + "maxLength": 64 + }, + "type": { + "type": "object", + "properties": { + "value": { + "enum": [ + "uqd", + "uqdb", + "qdc", + "camlock", + "npt", + "bsp", + "proprietary", + "", + null + ], + "type": "string", + "description": "* `uqd` - UQD (Universal Quick Disconnect)\n* `uqdb` - UQDB (Universal Quick Disconnect, Blind-mate)\n* `qdc` - QDC (Quick Disconnect Coupling)\n* `camlock` - Camlock (cam-and-groove)\n* `npt` - NPT (threaded)\n* `bsp` - BSP (threaded)\n* `proprietary` - Proprietary", + "x-spec-enum-id": "90159a314e98688d" + }, + "label": { + "type": "string", + "enum": [ + "UQD (Universal Quick Disconnect)", + "UQDB (Universal Quick Disconnect, Blind-mate)", + "QDC (Quick Disconnect Coupling)", + "Camlock (cam-and-groove)", + "NPT (threaded)", + "BSP (threaded)", + "Proprietary" + ] + } + }, + "nullable": true + }, + "diameter": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "diameter_unit": { + "type": "object", + "properties": { + "value": { + "enum": [ + "mm", + "cm", + "in", + "", + null + ], + "type": "string", + "description": "* `mm` - Millimeters\n* `cm` - Centimeters\n* `in` - Inches", + "x-spec-enum-id": "2eee9703528be3dd" + }, + "label": { + "type": "string", + "enum": [ + "Millimeters", + "Centimeters", + "Inches" + ] + } + }, + "nullable": true + }, + "cooling_intake": { + "allOf": [ + { + "$ref": "#/components/schemas/BriefCoolingIntakeTemplate" + } + ], + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "created": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "last_updated": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + } + }, + "required": [ + "created", + "display", + "id", + "last_updated", + "name", + "url" + ] + }, + "CoolingOutflowTemplateRequest": { + "type": "object", + "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)", + "properties": { + "device_type": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefDeviceTypeRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "module_type": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefModuleTypeRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "description": "{module} is accepted as a substitution for the module bay position when attached to a module type.", + "maxLength": 64 + }, + "label": { + "type": "string", + "description": "Physical label", + "maxLength": 64 + }, + "type": { + "enum": [ + "uqd", + "uqdb", + "qdc", + "camlock", + "npt", + "bsp", + "proprietary", + "", + null + ], + "type": "string", + "description": "* `uqd` - UQD (Universal Quick Disconnect)\n* `uqdb` - UQDB (Universal Quick Disconnect, Blind-mate)\n* `qdc` - QDC (Quick Disconnect Coupling)\n* `camlock` - Camlock (cam-and-groove)\n* `npt` - NPT (threaded)\n* `bsp` - BSP (threaded)\n* `proprietary` - Proprietary", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + }, + "diameter": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "diameter_unit": { + "enum": [ + "mm", + "cm", + "in", + "", + null + ], + "type": "string", + "description": "* `mm` - Millimeters\n* `cm` - Centimeters\n* `in` - Inches", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + }, + "cooling_intake": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefCoolingIntakeTemplateRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + } + }, + "required": [ + "name" + ] + }, + "CoolingSource": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "display_url": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "display": { + "type": "string", + "readOnly": true + }, + "site": { + "$ref": "#/components/schemas/BriefSite" + }, + "location": { + "allOf": [ + { + "$ref": "#/components/schemas/BriefLocation" + } + ], + "nullable": true + }, + "name": { + "type": "string", + "maxLength": 100 + }, + "type": { + "type": "object", + "properties": { + "value": { + "enum": [ + "chiller", + "cooling-tower", + "dry-cooler", + "crac", + "crah" + ], + "type": "string", + "description": "* `chiller` - Chiller\n* `cooling-tower` - Cooling tower\n* `dry-cooler` - Dry cooler\n* `crac` - CRAC\n* `crah` - CRAH", + "x-spec-enum-id": "f225f830b0d77ac5" + }, + "label": { + "type": "string", + "enum": [ + "Chiller", + "Cooling tower", + "Dry cooler", + "CRAC", + "CRAH" + ] + } + } + }, + "status": { + "type": "object", + "properties": { + "value": { + "enum": [ + "offline", + "active", + "planned", + "failed" + ], + "type": "string", + "description": "* `offline` - Offline\n* `active` - Active\n* `planned` - Planned\n* `failed` - Failed", + "x-spec-enum-id": "ec530572dc778583" + }, + "label": { + "type": "string", + "enum": [ + "Offline", + "Active", + "Planned", + "Failed" + ] + } + } + }, + "fluid_type": { + "type": "object", + "properties": { + "value": { + "enum": [ + "water", + "water-glycol", + "dielectric", + "refrigerant", + "", + null + ], + "type": "string", + "description": "* `water` - Water\n* `water-glycol` - Water/glycol\n* `dielectric` - Dielectric\n* `refrigerant` - Refrigerant", + "x-spec-enum-id": "b558e2a3f7349765" + }, + "label": { + "type": "string", + "enum": [ + "Water", + "Water/glycol", + "Dielectric", + "Refrigerant" + ] + } + }, + "nullable": true + }, + "cooling_capacity": { + "type": "number", + "format": "double", + "maximum": 100000000, + "minimum": 0, + "exclusiveMaximum": true, + "nullable": true, + "description": "Total rated cooling capacity (kW)" + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwner" + } + ], + "nullable": true + }, + "comments": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTag" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + }, + "coolingfeed_count": { + "type": "integer", + "format": "int64", + "readOnly": true + }, + "created": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "last_updated": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + } + }, + "required": [ + "coolingfeed_count", + "created", + "display", + "display_url", + "id", + "last_updated", + "name", + "site", + "type", + "url" + ] + }, + "CoolingSourceRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "site": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefSiteRequest" + } + ] + }, + "location": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefLocationRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "type": { + "enum": [ + "chiller", + "cooling-tower", + "dry-cooler", + "crac", + "crah" + ], + "type": "string", + "description": "* `chiller` - Chiller\n* `cooling-tower` - Cooling tower\n* `dry-cooler` - Dry cooler\n* `crac` - CRAC\n* `crah` - CRAH", + "x-spec-enum-id": "f225f830b0d77ac5" + }, + "status": { + "enum": [ + "offline", + "active", + "planned", + "failed" + ], + "type": "string", + "description": "* `offline` - Offline\n* `active` - Active\n* `planned` - Planned\n* `failed` - Failed", + "x-spec-enum-id": "ec530572dc778583" + }, + "fluid_type": { + "enum": [ + "water", + "water-glycol", + "dielectric", + "refrigerant", + "", + null + ], + "type": "string", + "description": "* `water` - Water\n* `water-glycol` - Water/glycol\n* `dielectric` - Dielectric\n* `refrigerant` - Refrigerant", + "x-spec-enum-id": "b558e2a3f7349765", + "nullable": true + }, + "cooling_capacity": { + "type": "number", + "format": "double", + "maximum": 100000000, + "minimum": 0, + "exclusiveMaximum": true, + "nullable": true, + "description": "Total rated cooling capacity (kW)" + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "comments": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "name", + "site", + "type" + ] + }, "CreateAvailableVLANRequest": { "type": "object", "description": "Adds support for custom fields and tags.", @@ -263359,8 +296875,8 @@ "multiobject" ], "type": "string", - "description": "* `text` - Text\n* `longtext` - Text (long)\n* `integer` - Integer\n* `decimal` - Decimal\n* `boolean` - Boolean (true/false)\n* `date` - Date\n* `datetime` - Date & time\n* `url` - URL\n* `json` - JSON\n* `select` - Selection\n* `multiselect` - Multiple selection\n* `object` - Object\n* `multiobject` - Multiple objects", - "x-spec-enum-id": "47c52a3d983e924c" + "description": "* `text` - Text\n* `longtext` - Text (long)\n* `integer` - Integer\n* `decimal` - Decimal\n* `boolean` - Boolean\n* `date` - Date\n* `datetime` - Date & time\n* `url` - URL\n* `json` - JSON\n* `select` - Selection\n* `multiselect` - Multiple selection\n* `object` - Object\n* `multiobject` - Multiple objects", + "x-spec-enum-id": "6ec6eff91d34cc44" }, "label": { "type": "string", @@ -263369,7 +296885,7 @@ "Text (long)", "Integer", "Decimal", - "Boolean (true/false)", + "Boolean", "Date", "Date & time", "URL", @@ -263498,6 +297014,10 @@ "type": "boolean", "description": "Replicate this value when cloning objects" }, + "nulls_first": { + "type": "boolean", + "description": "Sort null values before non-null values when ordering by this field" + }, "default": { "nullable": true, "description": "Default value for the field (must be a JSON value). Encapsulate strings with double quotes (e.g. \"Foo\")." @@ -263552,6 +297072,30 @@ ], "nullable": true }, + "status": { + "type": "object", + "properties": { + "value": { + "enum": [ + "active", + "provisioning", + "deleting" + ], + "type": "string", + "description": "* `active` - Active\n* `provisioning` - Provisioning\n* `deleting` - Deleting", + "x-spec-enum-id": "0b370227c3205532" + }, + "label": { + "type": "string", + "enum": [ + "Active", + "Provisioning", + "Deleting" + ] + } + }, + "readOnly": true + }, "owner": { "allOf": [ { @@ -263585,6 +297129,7 @@ "last_updated", "name", "object_types", + "status", "type", "url" ] @@ -263827,8 +297372,8 @@ "multiobject" ], "type": "string", - "description": "* `text` - Text\n* `longtext` - Text (long)\n* `integer` - Integer\n* `decimal` - Decimal\n* `boolean` - Boolean (true/false)\n* `date` - Date\n* `datetime` - Date & time\n* `url` - URL\n* `json` - JSON\n* `select` - Selection\n* `multiselect` - Multiple selection\n* `object` - Object\n* `multiobject` - Multiple objects", - "x-spec-enum-id": "47c52a3d983e924c" + "description": "* `text` - Text\n* `longtext` - Text (long)\n* `integer` - Integer\n* `decimal` - Decimal\n* `boolean` - Boolean\n* `date` - Date\n* `datetime` - Date & time\n* `url` - URL\n* `json` - JSON\n* `select` - Selection\n* `multiselect` - Multiple selection\n* `object` - Object\n* `multiobject` - Multiple objects", + "x-spec-enum-id": "6ec6eff91d34cc44" }, "related_object_type": { "type": "string", @@ -263904,6 +297449,10 @@ "type": "boolean", "description": "Replicate this value when cloning objects" }, + "nulls_first": { + "type": "boolean", + "description": "Sort null values before non-null values when ordering by this field" + }, "default": { "nullable": true, "description": "Default value for the field (must be a JSON value). Encapsulate strings with double quotes (e.g. \"Foo\")." @@ -264728,6 +298277,34 @@ } } }, + "cooling_method": { + "type": "object", + "properties": { + "value": { + "enum": [ + "air", + "liquid", + "hybrid", + "immersion", + "", + null + ], + "type": "string", + "description": "* `air` - Air\n* `liquid` - Liquid\n* `hybrid` - Hybrid\n* `immersion` - Immersion", + "x-spec-enum-id": "fff5375d415a4fdc" + }, + "label": { + "type": "string", + "enum": [ + "Air", + "Liquid", + "Hybrid", + "Immersion" + ] + } + }, + "nullable": true + }, "primary_ip": { "allOf": [ { @@ -264813,6 +298390,10 @@ ], "nullable": true }, + "config_context": { + "nullable": true, + "readOnly": true + }, "local_context_data": { "nullable": true, "description": "Local config context data takes precedence over source contexts in the final rendered config context" @@ -264855,6 +298436,14 @@ "type": "integer", "readOnly": true }, + "cooling_intake_count": { + "type": "integer", + "readOnly": true + }, + "cooling_outflow_count": { + "type": "integer", + "readOnly": true + }, "interface_count": { "type": "integer", "readOnly": true @@ -264881,8 +298470,11 @@ } }, "required": [ + "config_context", "console_port_count", "console_server_port_count", + "cooling_intake_count", + "cooling_outflow_count", "created", "device_bay_count", "device_type", @@ -265178,6 +298770,355 @@ "name" ] }, + "DeviceRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "name": { + "type": "string", + "nullable": true, + "maxLength": 64 + }, + "device_type": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefDeviceTypeRequest" + } + ] + }, + "role": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefDeviceRoleRequest" + } + ] + }, + "tenant": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefTenantRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "platform": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefPlatformRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "serial": { + "type": "string", + "title": "Serial number", + "description": "Chassis serial number, assigned by the manufacturer", + "maxLength": 50 + }, + "asset_tag": { + "type": "string", + "nullable": true, + "description": "A unique tag used to identify this device", + "maxLength": 50 + }, + "site": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefSiteRequest" + } + ] + }, + "location": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefLocationRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "rack": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefRackRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "position": { + "type": "number", + "format": "double", + "maximum": 1000, + "minimum": 0.5, + "exclusiveMaximum": true, + "nullable": true, + "title": "Position (U)" + }, + "face": { + "enum": [ + "front", + "rear", + "" + ], + "type": "string", + "description": "* `front` - Front\n* `rear` - Rear", + "x-spec-enum-id": "d2fb9b3f75158b83" + }, + "latitude": { + "type": "number", + "format": "double", + "maximum": 90.0, + "minimum": -90.0, + "nullable": true, + "description": "GPS coordinate in decimal format (xx.yyyyyy)" + }, + "longitude": { + "type": "number", + "format": "double", + "maximum": 180.0, + "minimum": -180.0, + "nullable": true, + "description": "GPS coordinate in decimal format (xx.yyyyyy)" + }, + "status": { + "enum": [ + "offline", + "active", + "planned", + "staged", + "failed", + "inventory", + "decommissioning" + ], + "type": "string", + "description": "* `offline` - Offline\n* `active` - Active\n* `planned` - Planned\n* `staged` - Staged\n* `failed` - Failed\n* `inventory` - Inventory\n* `decommissioning` - Decommissioning", + "x-spec-enum-id": "65feb4244cc9110c" + }, + "airflow": { + "enum": [ + "front-to-rear", + "rear-to-front", + "left-to-right", + "right-to-left", + "side-to-rear", + "rear-to-side", + "bottom-to-top", + "top-to-bottom", + "passive", + "mixed", + "" + ], + "type": "string", + "description": "* `front-to-rear` - Front to rear\n* `rear-to-front` - Rear to front\n* `left-to-right` - Left to right\n* `right-to-left` - Right to left\n* `side-to-rear` - Side to rear\n* `rear-to-side` - Rear to side\n* `bottom-to-top` - Bottom to top\n* `top-to-bottom` - Top to bottom\n* `passive` - Passive\n* `mixed` - Mixed", + "x-spec-enum-id": "11cb3d363b41ba9e" + }, + "cooling_method": { + "enum": [ + "air", + "liquid", + "hybrid", + "immersion", + "", + null + ], + "type": "string", + "description": "* `air` - Air\n* `liquid` - Liquid\n* `hybrid` - Hybrid\n* `immersion` - Immersion", + "x-spec-enum-id": "fff5375d415a4fdc", + "nullable": true + }, + "primary_ip4": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefIPAddressRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "primary_ip6": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefIPAddressRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "oob_ip": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefIPAddressRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "cluster": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefClusterRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "virtual_chassis": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefVirtualChassisRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "vc_position": { + "type": "integer", + "maximum": 255, + "minimum": 0, + "nullable": true + }, + "vc_priority": { + "type": "integer", + "maximum": 255, + "minimum": 0, + "nullable": true, + "description": "Virtual chassis master election priority" + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "comments": { + "type": "string" + }, + "config_template": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefConfigTemplateRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "local_context_data": { + "nullable": true, + "description": "Local config context data takes precedence over source contexts in the final rendered config context" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "device_type", + "role", + "site" + ] + }, "DeviceRole": { "type": "object", "description": "Base serializer class for models inheriting from NestedGroupModel.", @@ -265517,6 +299458,34 @@ }, "nullable": true }, + "cooling_method": { + "type": "object", + "properties": { + "value": { + "enum": [ + "air", + "liquid", + "hybrid", + "immersion", + "", + null + ], + "type": "string", + "description": "* `air` - Air\n* `liquid` - Liquid\n* `hybrid` - Hybrid\n* `immersion` - Immersion", + "x-spec-enum-id": "fff5375d415a4fdc" + }, + "label": { + "type": "string", + "enum": [ + "Air", + "Liquid", + "Hybrid", + "Immersion" + ] + } + }, + "nullable": true + }, "weight": { "type": "number", "format": "double", @@ -265554,6 +299523,12 @@ }, "nullable": true }, + "end_of_life": { + "type": "string", + "format": "date", + "nullable": true, + "description": "The date after which this device type is no longer supported by the manufacturer" + }, "front_image": { "type": "string", "format": "uri", @@ -265621,6 +299596,14 @@ "type": "integer", "readOnly": true }, + "cooling_intake_template_count": { + "type": "integer", + "readOnly": true + }, + "cooling_outflow_template_count": { + "type": "integer", + "readOnly": true + }, "interface_template_count": { "type": "integer", "readOnly": true @@ -265649,6 +299632,8 @@ "required": [ "console_port_template_count", "console_server_port_template_count", + "cooling_intake_template_count", + "cooling_outflow_template_count", "created", "device_bay_template_count", "device_count", @@ -265764,6 +299749,20 @@ "x-spec-enum-id": "11cb3d363b41ba9e", "nullable": true }, + "cooling_method": { + "enum": [ + "air", + "liquid", + "hybrid", + "immersion", + "", + null + ], + "type": "string", + "description": "* `air` - Air\n* `liquid` - Liquid\n* `hybrid` - Hybrid\n* `immersion` - Immersion", + "x-spec-enum-id": "fff5375d415a4fdc", + "nullable": true + }, "weight": { "type": "number", "format": "double", @@ -265787,6 +299786,12 @@ "x-spec-enum-id": "2235ce3f404afbc0", "nullable": true }, + "end_of_life": { + "type": "string", + "format": "date", + "nullable": true, + "description": "The date after which this device type is no longer supported by the manufacturer" + }, "front_image": { "type": "string", "format": "binary", @@ -265837,727 +299842,6 @@ "slug" ] }, - "DeviceWithConfigContext": { - "type": "object", - "description": "Base serializer class for models inheriting from PrimaryModel.", - "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "url": { - "type": "string", - "format": "uri", - "readOnly": true - }, - "display_url": { - "type": "string", - "format": "uri", - "readOnly": true - }, - "display": { - "type": "string", - "readOnly": true - }, - "name": { - "type": "string", - "nullable": true, - "maxLength": 64 - }, - "device_type": { - "$ref": "#/components/schemas/BriefDeviceType" - }, - "role": { - "$ref": "#/components/schemas/BriefDeviceRole" - }, - "tenant": { - "allOf": [ - { - "$ref": "#/components/schemas/BriefTenant" - } - ], - "nullable": true - }, - "platform": { - "allOf": [ - { - "$ref": "#/components/schemas/BriefPlatform" - } - ], - "nullable": true - }, - "serial": { - "type": "string", - "title": "Serial number", - "description": "Chassis serial number, assigned by the manufacturer", - "maxLength": 50 - }, - "asset_tag": { - "type": "string", - "nullable": true, - "description": "A unique tag used to identify this device", - "maxLength": 50 - }, - "site": { - "$ref": "#/components/schemas/BriefSite" - }, - "location": { - "allOf": [ - { - "$ref": "#/components/schemas/BriefLocation" - } - ], - "nullable": true - }, - "rack": { - "allOf": [ - { - "$ref": "#/components/schemas/BriefRack" - } - ], - "nullable": true - }, - "position": { - "type": "number", - "format": "double", - "maximum": 1000, - "minimum": 0.5, - "exclusiveMaximum": true, - "nullable": true, - "title": "Position (U)" - }, - "face": { - "type": "object", - "properties": { - "value": { - "enum": [ - "front", - "rear", - "" - ], - "type": "string", - "description": "* `front` - Front\n* `rear` - Rear", - "x-spec-enum-id": "d2fb9b3f75158b83" - }, - "label": { - "type": "string", - "enum": [ - "Front", - "Rear" - ] - } - } - }, - "latitude": { - "type": "number", - "format": "double", - "maximum": 90.0, - "minimum": -90.0, - "nullable": true, - "description": "GPS coordinate in decimal format (xx.yyyyyy)" - }, - "longitude": { - "type": "number", - "format": "double", - "maximum": 180.0, - "minimum": -180.0, - "nullable": true, - "description": "GPS coordinate in decimal format (xx.yyyyyy)" - }, - "parent_device": { - "allOf": [ - { - "$ref": "#/components/schemas/NestedDevice" - } - ], - "nullable": true, - "readOnly": true - }, - "status": { - "type": "object", - "properties": { - "value": { - "enum": [ - "offline", - "active", - "planned", - "staged", - "failed", - "inventory", - "decommissioning" - ], - "type": "string", - "description": "* `offline` - Offline\n* `active` - Active\n* `planned` - Planned\n* `staged` - Staged\n* `failed` - Failed\n* `inventory` - Inventory\n* `decommissioning` - Decommissioning", - "x-spec-enum-id": "65feb4244cc9110c" - }, - "label": { - "type": "string", - "enum": [ - "Offline", - "Active", - "Planned", - "Staged", - "Failed", - "Inventory", - "Decommissioning" - ] - } - } - }, - "airflow": { - "type": "object", - "properties": { - "value": { - "enum": [ - "front-to-rear", - "rear-to-front", - "left-to-right", - "right-to-left", - "side-to-rear", - "rear-to-side", - "bottom-to-top", - "top-to-bottom", - "passive", - "mixed", - "" - ], - "type": "string", - "description": "* `front-to-rear` - Front to rear\n* `rear-to-front` - Rear to front\n* `left-to-right` - Left to right\n* `right-to-left` - Right to left\n* `side-to-rear` - Side to rear\n* `rear-to-side` - Rear to side\n* `bottom-to-top` - Bottom to top\n* `top-to-bottom` - Top to bottom\n* `passive` - Passive\n* `mixed` - Mixed", - "x-spec-enum-id": "11cb3d363b41ba9e" - }, - "label": { - "type": "string", - "enum": [ - "Front to rear", - "Rear to front", - "Left to right", - "Right to left", - "Side to rear", - "Rear to side", - "Bottom to top", - "Top to bottom", - "Passive", - "Mixed" - ] - } - } - }, - "primary_ip": { - "allOf": [ - { - "$ref": "#/components/schemas/BriefIPAddress" - } - ], - "readOnly": true, - "nullable": true - }, - "primary_ip4": { - "allOf": [ - { - "$ref": "#/components/schemas/BriefIPAddress" - } - ], - "nullable": true - }, - "primary_ip6": { - "allOf": [ - { - "$ref": "#/components/schemas/BriefIPAddress" - } - ], - "nullable": true - }, - "oob_ip": { - "allOf": [ - { - "$ref": "#/components/schemas/BriefIPAddress" - } - ], - "nullable": true - }, - "cluster": { - "allOf": [ - { - "$ref": "#/components/schemas/BriefCluster" - } - ], - "nullable": true - }, - "virtual_chassis": { - "allOf": [ - { - "$ref": "#/components/schemas/BriefVirtualChassis" - } - ], - "nullable": true - }, - "vc_position": { - "type": "integer", - "maximum": 255, - "minimum": 0, - "nullable": true - }, - "vc_priority": { - "type": "integer", - "maximum": 255, - "minimum": 0, - "nullable": true, - "description": "Virtual chassis master election priority" - }, - "description": { - "type": "string", - "maxLength": 200 - }, - "owner": { - "allOf": [ - { - "$ref": "#/components/schemas/BriefOwner" - } - ], - "nullable": true - }, - "comments": { - "type": "string" - }, - "config_template": { - "allOf": [ - { - "$ref": "#/components/schemas/BriefConfigTemplate" - } - ], - "nullable": true - }, - "config_context": { - "nullable": true, - "readOnly": true - }, - "local_context_data": { - "nullable": true, - "description": "Local config context data takes precedence over source contexts in the final rendered config context" - }, - "tags": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NestedTag" - } - }, - "custom_fields": { - "type": "object", - "additionalProperties": {} - }, - "created": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "last_updated": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "console_port_count": { - "type": "integer", - "readOnly": true - }, - "console_server_port_count": { - "type": "integer", - "readOnly": true - }, - "power_port_count": { - "type": "integer", - "readOnly": true - }, - "power_outlet_count": { - "type": "integer", - "readOnly": true - }, - "interface_count": { - "type": "integer", - "readOnly": true - }, - "front_port_count": { - "type": "integer", - "readOnly": true - }, - "rear_port_count": { - "type": "integer", - "readOnly": true - }, - "device_bay_count": { - "type": "integer", - "readOnly": true - }, - "module_bay_count": { - "type": "integer", - "readOnly": true - }, - "inventory_item_count": { - "type": "integer", - "readOnly": true - } - }, - "required": [ - "config_context", - "console_port_count", - "console_server_port_count", - "created", - "device_bay_count", - "device_type", - "display", - "display_url", - "front_port_count", - "id", - "interface_count", - "inventory_item_count", - "last_updated", - "module_bay_count", - "parent_device", - "power_outlet_count", - "power_port_count", - "primary_ip", - "rear_port_count", - "role", - "site", - "url" - ] - }, - "DeviceWithConfigContextRequest": { - "type": "object", - "description": "Base serializer class for models inheriting from PrimaryModel.", - "properties": { - "name": { - "type": "string", - "nullable": true, - "maxLength": 64 - }, - "device_type": { - "oneOf": [ - { - "type": "integer" - }, - { - "$ref": "#/components/schemas/BriefDeviceTypeRequest" - } - ] - }, - "role": { - "oneOf": [ - { - "type": "integer" - }, - { - "$ref": "#/components/schemas/BriefDeviceRoleRequest" - } - ] - }, - "tenant": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefTenantRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "platform": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefPlatformRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "serial": { - "type": "string", - "title": "Serial number", - "description": "Chassis serial number, assigned by the manufacturer", - "maxLength": 50 - }, - "asset_tag": { - "type": "string", - "nullable": true, - "description": "A unique tag used to identify this device", - "maxLength": 50 - }, - "site": { - "oneOf": [ - { - "type": "integer" - }, - { - "$ref": "#/components/schemas/BriefSiteRequest" - } - ] - }, - "location": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefLocationRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "rack": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefRackRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "position": { - "type": "number", - "format": "double", - "maximum": 1000, - "minimum": 0.5, - "exclusiveMaximum": true, - "nullable": true, - "title": "Position (U)" - }, - "face": { - "enum": [ - "front", - "rear", - "" - ], - "type": "string", - "description": "* `front` - Front\n* `rear` - Rear", - "x-spec-enum-id": "d2fb9b3f75158b83" - }, - "latitude": { - "type": "number", - "format": "double", - "maximum": 90.0, - "minimum": -90.0, - "nullable": true, - "description": "GPS coordinate in decimal format (xx.yyyyyy)" - }, - "longitude": { - "type": "number", - "format": "double", - "maximum": 180.0, - "minimum": -180.0, - "nullable": true, - "description": "GPS coordinate in decimal format (xx.yyyyyy)" - }, - "status": { - "enum": [ - "offline", - "active", - "planned", - "staged", - "failed", - "inventory", - "decommissioning" - ], - "type": "string", - "description": "* `offline` - Offline\n* `active` - Active\n* `planned` - Planned\n* `staged` - Staged\n* `failed` - Failed\n* `inventory` - Inventory\n* `decommissioning` - Decommissioning", - "x-spec-enum-id": "65feb4244cc9110c" - }, - "airflow": { - "enum": [ - "front-to-rear", - "rear-to-front", - "left-to-right", - "right-to-left", - "side-to-rear", - "rear-to-side", - "bottom-to-top", - "top-to-bottom", - "passive", - "mixed", - "" - ], - "type": "string", - "description": "* `front-to-rear` - Front to rear\n* `rear-to-front` - Rear to front\n* `left-to-right` - Left to right\n* `right-to-left` - Right to left\n* `side-to-rear` - Side to rear\n* `rear-to-side` - Rear to side\n* `bottom-to-top` - Bottom to top\n* `top-to-bottom` - Top to bottom\n* `passive` - Passive\n* `mixed` - Mixed", - "x-spec-enum-id": "11cb3d363b41ba9e" - }, - "primary_ip4": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefIPAddressRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "primary_ip6": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefIPAddressRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "oob_ip": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefIPAddressRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "cluster": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefClusterRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "virtual_chassis": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefVirtualChassisRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "vc_position": { - "type": "integer", - "maximum": 255, - "minimum": 0, - "nullable": true - }, - "vc_priority": { - "type": "integer", - "maximum": 255, - "minimum": 0, - "nullable": true, - "description": "Virtual chassis master election priority" - }, - "description": { - "type": "string", - "maxLength": 200 - }, - "owner": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefOwnerRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "comments": { - "type": "string" - }, - "config_template": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefConfigTemplateRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "local_context_data": { - "nullable": true, - "description": "Local config context data takes precedence over source contexts in the final rendered config context" - }, - "tags": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NestedTagRequest" - } - }, - "custom_fields": { - "type": "object", - "additionalProperties": {} - } - }, - "required": [ - "device_type", - "role", - "site" - ] - }, "EventRule": { "type": "object", "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", @@ -266639,7 +299923,8 @@ } }, "action_object_type": { - "type": "string" + "type": "string", + "nullable": true }, "action_object_id": { "type": "integer", @@ -266652,6 +299937,10 @@ "readOnly": true, "nullable": true }, + "action_is_available": { + "type": "boolean", + "readOnly": true + }, "description": { "type": "string", "maxLength": 200 @@ -266688,8 +299977,8 @@ } }, "required": [ + "action_is_available", "action_object", - "action_object_type", "action_type", "created", "display", @@ -266753,7 +300042,8 @@ "x-spec-enum-id": "287901b937995956" }, "action_object_type": { - "type": "string" + "type": "string", + "nullable": true }, "action_object_id": { "type": "integer", @@ -266794,7 +300084,6 @@ } }, "required": [ - "action_object_type", "action_type", "event_types", "name", @@ -270359,7 +303648,7 @@ }, "Interface": { "type": "object", - "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", + "description": "Mixin for Interface and VMInterface serializers that adds a `mac_address` shortcut field for\ncreating/updating the primary MACAddress in a single request. The validated write is centralized\non the interface model (BaseInterface.set_primary_mac_address); this mixin is a thin adapter that\nowns the permission check, the shortcut-vs-primary_mac_address conflict guard, and translating the\nmodel's validation errors into API errors.", "properties": { "id": { "type": "integer", @@ -270413,6 +303702,7 @@ "virtual", "bridge", "lag", + "channel", "100base-fx", "100base-lfx", "100base-tx", @@ -270639,8 +303929,8 @@ "other" ], "type": "string", - "description": "* `virtual` - Virtual\n* `bridge` - Bridge\n* `lag` - Link Aggregation Group (LAG)\n* `100base-fx` - 100BASE-FX (10/100ME)\n* `100base-lfx` - 100BASE-LFX (10/100ME)\n* `100base-tx` - 100BASE-TX (10/100ME)\n* `100base-t1` - 100BASE-T1 (10/100ME)\n* `1000base-bx10-d` - 1000BASE-BX10-D (1GE BiDi Down)\n* `1000base-bx10-u` - 1000BASE-BX10-U (1GE BiDi Up)\n* `1000base-cwdm` - 1000BASE-CWDM (1GE)\n* `1000base-cx` - 1000BASE-CX (1GE DAC)\n* `1000base-dwdm` - 1000BASE-DWDM (1GE)\n* `1000base-ex` - 1000BASE-EX (1GE)\n* `1000base-lsx` - 1000BASE-LSX (1GE)\n* `1000base-lx` - 1000BASE-LX (1GE)\n* `1000base-lx10` - 1000BASE-LX10/LH (1GE)\n* `1000base-sx` - 1000BASE-SX (1GE)\n* `1000base-t` - 1000BASE-T (1GE)\n* `1000base-tx` - 1000BASE-TX (1GE)\n* `1000base-zx` - 1000BASE-ZX (1GE)\n* `2.5gbase-t` - 2.5GBASE-T (2.5GE)\n* `5gbase-t` - 5GBASE-T (5GE)\n* `10gbase-br-d` - 10GBASE-BR-D (10GE BiDi Down)\n* `10gbase-br-u` - 10GBASE-BR-U (10GE BiDi Up)\n* `10gbase-cu` - 10GBASE-CU (10GE DAC Passive Twinax)\n* `10gbase-cx4` - 10GBASE-CX4 (10GE DAC)\n* `10gbase-er` - 10GBASE-ER (10GE)\n* `10gbase-lr` - 10GBASE-LR (10GE)\n* `10gbase-lrm` - 10GBASE-LRM (10GE)\n* `10gbase-lx4` - 10GBASE-LX4 (10GE)\n* `10gbase-sr` - 10GBASE-SR (10GE)\n* `10gbase-t` - 10GBASE-T (10GE)\n* `10gbase-zr` - 10GBASE-ZR (10GE)\n* `25gbase-cr` - 25GBASE-CR (25GE DAC)\n* `25gbase-er` - 25GBASE-ER (25GE)\n* `25gbase-lr` - 25GBASE-LR (25GE)\n* `25gbase-sr` - 25GBASE-SR (25GE)\n* `25gbase-t` - 25GBASE-T (25GE)\n* `40gbase-cr4` - 40GBASE-CR4 (40GE DAC)\n* `40gbase-er4` - 40GBASE-ER4 (40GE)\n* `40gbase-fr4` - 40GBASE-FR4 (40GE)\n* `40gbase-lr4` - 40GBASE-LR4 (40GE)\n* `40gbase-sr4` - 40GBASE-SR4 (40GE)\n* `40gbase-sr4-bd` - 40GBASE-SR4 (40GE BiDi)\n* `50gbase-cr` - 50GBASE-CR (50GE DAC)\n* `50gbase-er` - 50GBASE-ER (50GE)\n* `50gbase-fr` - 50GBASE-FR (50GE)\n* `50gbase-lr` - 50GBASE-LR (50GE)\n* `50gbase-sr` - 50GBASE-SR (50GE)\n* `100gbase-cr1` - 100GBASE-CR1 (100GE DAC)\n* `100gbase-cr2` - 100GBASE-CR2 (100GE DAC)\n* `100gbase-cr4` - 100GBASE-CR4 (100GE DAC)\n* `100gbase-cr10` - 100GBASE-CR10 (100GE DAC)\n* `100gbase-cwdm4` - 100GBASE-CWDM4 (100GE)\n* `100gbase-dr` - 100GBASE-DR (100GE)\n* `100gbase-er4` - 100GBASE-ER4 (100GE)\n* `100gbase-fr1` - 100GBASE-FR1 (100GE)\n* `100gbase-lr1` - 100GBASE-LR1 (100GE)\n* `100gbase-lr4` - 100GBASE-LR4 (100GE)\n* `100gbase-sr1` - 100GBASE-SR1 (100GE)\n* `100gbase-sr1.2` - 100GBASE-SR1.2 (100GE BiDi)\n* `100gbase-sr2` - 100GBASE-SR2 (100GE)\n* `100gbase-sr4` - 100GBASE-SR4 (100GE)\n* `100gbase-sr10` - 100GBASE-SR10 (100GE)\n* `100gbase-zr` - 100GBASE-ZR (100GE)\n* `200gbase-cr2` - 200GBASE-CR2 (200GE)\n* `200gbase-cr4` - 200GBASE-CR4 (200GE)\n* `200gbase-dr4` - 200GBASE-DR4 (200GE)\n* `200gbase-er4` - 200GBASE-ER4 (200GE)\n* `200gbase-fr4` - 200GBASE-FR4 (200GE)\n* `200gbase-lr4` - 200GBASE-LR4 (200GE)\n* `200gbase-sr2` - 200GBASE-SR2 (200GE)\n* `200gbase-sr4` - 200GBASE-SR4 (200GE)\n* `200gbase-vr2` - 200GBASE-VR2 (200GE)\n* `400gbase-cr4` - 400GBASE-CR4 (400GE)\n* `400gbase-dr4` - 400GBASE-DR4 (400GE)\n* `400gbase-er8` - 400GBASE-ER8 (400GE)\n* `400gbase-fr4` - 400GBASE-FR4 (400GE)\n* `400gbase-fr8` - 400GBASE-FR8 (400GE)\n* `400gbase-lr4` - 400GBASE-LR4 (400GE)\n* `400gbase-lr8` - 400GBASE-LR8 (400GE)\n* `400gbase-sr4` - 400GBASE-SR4 (400GE)\n* `400gbase-sr4_2` - 400GBASE-SR4.2 (400GE BiDi)\n* `400gbase-sr8` - 400GBASE-SR8 (400GE)\n* `400gbase-sr16` - 400GBASE-SR16 (400GE)\n* `400gbase-vr4` - 400GBASE-VR4 (400GE)\n* `400gbase-zr` - 400GBASE-ZR (400GE)\n* `800gbase-cr8` - 800GBASE-CR8 (800GE)\n* `800gbase-dr8` - 800GBASE-DR8 (800GE)\n* `800gbase-sr8` - 800GBASE-SR8 (800GE)\n* `800gbase-vr8` - 800GBASE-VR8 (800GE)\n* `1.6tbase-cr8` - 1.6TBASE-CR8 (1.6TE)\n* `1.6tbase-dr8` - 1.6TBASE-DR8 (1.6TE)\n* `1.6tbase-dr8-2` - 1.6TBASE-DR8-2 (1.6TE)\n* `100base-x-sfp` - SFP (100ME)\n* `1000base-x-gbic` - GBIC (1GE)\n* `1000base-x-sfp` - SFP (1GE)\n* `2.5gbase-x-sfp` - SFP (2.5GE)\n* `10gbase-x-sfpp` - SFP+ (10GE)\n* `10gbase-x-xenpak` - XENPAK (10GE)\n* `10gbase-x-xfp` - XFP (10GE)\n* `10gbase-x-x2` - X2 (10GE)\n* `25gbase-x-sfp28` - SFP28 (25GE)\n* `40gbase-x-qsfpp` - QSFP+ (40GE)\n* `50gbase-x-sfp28` - QSFP28 (50GE)\n* `50gbase-x-sfp56` - SFP56 (50GE)\n* `100gbase-x-cfp` - CFP (100GE)\n* `100gbase-x-cfp2` - CFP2 (100GE)\n* `100gbase-x-cfp4` - CFP4 (100GE)\n* `100gbase-x-cxp` - CXP (100GE)\n* `100gbase-x-cpak` - Cisco CPAK (100GE)\n* `100gbase-x-dsfp` - DSFP (100GE)\n* `100gbase-x-qsfp28` - QSFP28 (100GE)\n* `100gbase-x-qsfpdd` - QSFP-DD (100GE)\n* `100gbase-x-sfp112` - SFP112 (100GE)\n* `100gbase-x-sfpdd` - SFP-DD (100GE)\n* `200gbase-x-cfp2` - CFP2 (200GE)\n* `200gbase-x-qsfp56` - QSFP56 (200GE)\n* `200gbase-x-qsfpdd` - QSFP-DD (200GE)\n* `400gbase-x-qsfp112` - QSFP112 (400GE)\n* `400gbase-x-qsfpdd` - QSFP-DD (400GE)\n* `400gbase-x-cdfp` - CDFP (400GE)\n* `400gbase-x-cfp2` - CFP2 (400GE)\n* `400gbase-x-cfp8` - CPF8 (400GE)\n* `400gbase-x-osfp` - OSFP (400GE)\n* `400gbase-x-osfp-rhs` - OSFP-RHS (400GE)\n* `800gbase-x-osfp` - OSFP (800GE)\n* `800gbase-x-qsfpdd` - QSFP-DD (800GE)\n* `1.6tbase-x-osfp1600` - OSFP1600 (1.6TE)\n* `1.6tbase-x-osfp1600-rhs` - OSFP1600-RHS (1.6TE)\n* `1.6tbase-x-qsfpdd1600` - QSFP-DD1600 (1.6TE)\n* `1000base-kx` - 1000BASE-KX (1GE)\n* `2.5gbase-kx` - 2.5GBASE-KX (2.5GE)\n* `5gbase-kr` - 5GBASE-KR (5GE)\n* `10gbase-kr` - 10GBASE-KR (10GE)\n* `10gbase-kx4` - 10GBASE-KX4 (10GE)\n* `25gbase-kr` - 25GBASE-KR (25GE)\n* `40gbase-kr4` - 40GBASE-KR4 (40GE)\n* `50gbase-kr` - 50GBASE-KR (50GE)\n* `100gbase-kp4` - 100GBASE-KP4 (100GE)\n* `100gbase-kr2` - 100GBASE-KR2 (100GE)\n* `100gbase-kr4` - 100GBASE-KR4 (100GE)\n* `1.6tbase-kr8` - 1.6TBASE-KR8 (1.6TE)\n* `ieee802.11a` - IEEE 802.11a\n* `ieee802.11g` - IEEE 802.11b/g\n* `ieee802.11n` - IEEE 802.11n (Wi-Fi 4)\n* `ieee802.11ac` - IEEE 802.11ac (Wi-Fi 5)\n* `ieee802.11ad` - IEEE 802.11ad (WiGig)\n* `ieee802.11ax` - IEEE 802.11ax (Wi-Fi 6)\n* `ieee802.11ay` - IEEE 802.11ay (WiGig)\n* `ieee802.11be` - IEEE 802.11be (Wi-Fi 7)\n* `ieee802.15.1` - IEEE 802.15.1 (Bluetooth)\n* `ieee802.15.4` - IEEE 802.15.4 (LR-WPAN)\n* `other-wireless` - Other (Wireless)\n* `gsm` - GSM\n* `cdma` - CDMA\n* `lte` - LTE\n* `4g` - 4G\n* `5g` - 5G\n* `sonet-oc3` - OC-3/STM-1\n* `sonet-oc12` - OC-12/STM-4\n* `sonet-oc48` - OC-48/STM-16\n* `sonet-oc192` - OC-192/STM-64\n* `sonet-oc768` - OC-768/STM-256\n* `sonet-oc1920` - OC-1920/STM-640\n* `sonet-oc3840` - OC-3840/STM-1234\n* `1gfc-sfp` - SFP (1GFC)\n* `2gfc-sfp` - SFP (2GFC)\n* `4gfc-sfp` - SFP (4GFC)\n* `8gfc-sfpp` - SFP+ (8GFC)\n* `16gfc-sfpp` - SFP+ (16GFC)\n* `32gfc-sfp28` - SFP28 (32GFC)\n* `32gfc-sfpp` - SFP+ (32GFC)\n* `64gfc-qsfpp` - QSFP+ (64GFC)\n* `64gfc-sfpdd` - SFP-DD (64GFC)\n* `64gfc-sfpp` - SFP+ (64GFC)\n* `128gfc-qsfp28` - QSFP28 (128GFC)\n* `infiniband-sdr` - SDR (2 Gbps)\n* `infiniband-ddr` - DDR (4 Gbps)\n* `infiniband-qdr` - QDR (8 Gbps)\n* `infiniband-fdr10` - FDR10 (10 Gbps)\n* `infiniband-fdr` - FDR (13.5 Gbps)\n* `infiniband-edr` - EDR (25 Gbps)\n* `infiniband-hdr` - HDR (50 Gbps)\n* `infiniband-ndr` - NDR (100 Gbps)\n* `infiniband-xdr` - XDR (200 Gbps)\n* `infiniband-sdr-4x` - SDR 4X (8 Gbps)\n* `infiniband-ddr-4x` - DDR 4X (16 Gbps)\n* `infiniband-qdr-4x` - QDR 4X (32 Gbps)\n* `infiniband-fdr10-4x` - FDR10 4X (40 Gbps)\n* `infiniband-fdr-4x` - FDR 4X (56 Gbps)\n* `infiniband-edr-4x` - EDR 4X (100 Gbps)\n* `infiniband-hdr-4x` - HDR 4X (200 Gbps)\n* `infiniband-ndr-4x` - NDR 4X (400 Gbps)\n* `infiniband-xdr-4x` - XDR 4X (800 Gbps)\n* `t1` - T1 (1.544 Mbps)\n* `e1` - E1 (2.048 Mbps)\n* `t3` - T3 (45 Mbps)\n* `e3` - E3 (34 Mbps)\n* `xdsl` - xDSL\n* `docsis` - DOCSIS\n* `moca` - MoCA\n* `bpon` - BPON (622 Mbps / 155 Mbps)\n* `epon` - EPON (1 Gbps)\n* `10g-epon` - 10G-EPON (10 Gbps)\n* `gpon` - GPON (2.5 Gbps / 1.25 Gbps)\n* `xg-pon` - XG-PON (10 Gbps / 2.5 Gbps)\n* `xgs-pon` - XGS-PON (10 Gbps)\n* `ng-pon2` - NG-PON2 (TWDM-PON) (4x10 Gbps)\n* `25g-pon` - 25G-PON (25 Gbps)\n* `50g-pon` - 50G-PON (50 Gbps)\n* `cisco-stackwise` - Cisco StackWise\n* `cisco-stackwise-plus` - Cisco StackWise Plus\n* `cisco-flexstack` - Cisco FlexStack\n* `cisco-flexstack-plus` - Cisco FlexStack Plus\n* `cisco-stackwise-80` - Cisco StackWise-80\n* `cisco-stackwise-160` - Cisco StackWise-160\n* `cisco-stackwise-320` - Cisco StackWise-320\n* `cisco-stackwise-480` - Cisco StackWise-480\n* `cisco-stackwise-1t` - Cisco StackWise-1T\n* `juniper-vcp` - Juniper VCP\n* `extreme-summitstack` - Extreme SummitStack\n* `extreme-summitstack-128` - Extreme SummitStack-128\n* `extreme-summitstack-256` - Extreme SummitStack-256\n* `extreme-summitstack-512` - Extreme SummitStack-512\n* `hpe-synergy-interconnect-link` - HPE Synergy Interconnect Link\n* `other` - Other", - "x-spec-enum-id": "b0c97040e5abdff1" + "description": "* `virtual` - Virtual\n* `bridge` - Bridge\n* `lag` - Link Aggregation Group (LAG)\n* `channel` - Channel\n* `100base-fx` - 100BASE-FX (10/100ME)\n* `100base-lfx` - 100BASE-LFX (10/100ME)\n* `100base-tx` - 100BASE-TX (10/100ME)\n* `100base-t1` - 100BASE-T1 (10/100ME)\n* `1000base-bx10-d` - 1000BASE-BX10-D (1GE BiDi Down)\n* `1000base-bx10-u` - 1000BASE-BX10-U (1GE BiDi Up)\n* `1000base-cwdm` - 1000BASE-CWDM (1GE)\n* `1000base-cx` - 1000BASE-CX (1GE DAC)\n* `1000base-dwdm` - 1000BASE-DWDM (1GE)\n* `1000base-ex` - 1000BASE-EX (1GE)\n* `1000base-lsx` - 1000BASE-LSX (1GE)\n* `1000base-lx` - 1000BASE-LX (1GE)\n* `1000base-lx10` - 1000BASE-LX10/LH (1GE)\n* `1000base-sx` - 1000BASE-SX (1GE)\n* `1000base-t` - 1000BASE-T (1GE)\n* `1000base-tx` - 1000BASE-TX (1GE)\n* `1000base-zx` - 1000BASE-ZX (1GE)\n* `2.5gbase-t` - 2.5GBASE-T (2.5GE)\n* `5gbase-t` - 5GBASE-T (5GE)\n* `10gbase-br-d` - 10GBASE-BR-D (10GE BiDi Down)\n* `10gbase-br-u` - 10GBASE-BR-U (10GE BiDi Up)\n* `10gbase-cu` - 10GBASE-CU (10GE DAC Passive Twinax)\n* `10gbase-cx4` - 10GBASE-CX4 (10GE DAC)\n* `10gbase-er` - 10GBASE-ER (10GE)\n* `10gbase-lr` - 10GBASE-LR (10GE)\n* `10gbase-lrm` - 10GBASE-LRM (10GE)\n* `10gbase-lx4` - 10GBASE-LX4 (10GE)\n* `10gbase-sr` - 10GBASE-SR (10GE)\n* `10gbase-t` - 10GBASE-T (10GE)\n* `10gbase-zr` - 10GBASE-ZR (10GE)\n* `25gbase-cr` - 25GBASE-CR (25GE DAC)\n* `25gbase-er` - 25GBASE-ER (25GE)\n* `25gbase-lr` - 25GBASE-LR (25GE)\n* `25gbase-sr` - 25GBASE-SR (25GE)\n* `25gbase-t` - 25GBASE-T (25GE)\n* `40gbase-cr4` - 40GBASE-CR4 (40GE DAC)\n* `40gbase-er4` - 40GBASE-ER4 (40GE)\n* `40gbase-fr4` - 40GBASE-FR4 (40GE)\n* `40gbase-lr4` - 40GBASE-LR4 (40GE)\n* `40gbase-sr4` - 40GBASE-SR4 (40GE)\n* `40gbase-sr4-bd` - 40GBASE-SR4 (40GE BiDi)\n* `50gbase-cr` - 50GBASE-CR (50GE DAC)\n* `50gbase-er` - 50GBASE-ER (50GE)\n* `50gbase-fr` - 50GBASE-FR (50GE)\n* `50gbase-lr` - 50GBASE-LR (50GE)\n* `50gbase-sr` - 50GBASE-SR (50GE)\n* `100gbase-cr1` - 100GBASE-CR1 (100GE DAC)\n* `100gbase-cr2` - 100GBASE-CR2 (100GE DAC)\n* `100gbase-cr4` - 100GBASE-CR4 (100GE DAC)\n* `100gbase-cr10` - 100GBASE-CR10 (100GE DAC)\n* `100gbase-cwdm4` - 100GBASE-CWDM4 (100GE)\n* `100gbase-dr` - 100GBASE-DR (100GE)\n* `100gbase-er4` - 100GBASE-ER4 (100GE)\n* `100gbase-fr1` - 100GBASE-FR1 (100GE)\n* `100gbase-lr1` - 100GBASE-LR1 (100GE)\n* `100gbase-lr4` - 100GBASE-LR4 (100GE)\n* `100gbase-sr1` - 100GBASE-SR1 (100GE)\n* `100gbase-sr1.2` - 100GBASE-SR1.2 (100GE BiDi)\n* `100gbase-sr2` - 100GBASE-SR2 (100GE)\n* `100gbase-sr4` - 100GBASE-SR4 (100GE)\n* `100gbase-sr10` - 100GBASE-SR10 (100GE)\n* `100gbase-zr` - 100GBASE-ZR (100GE)\n* `200gbase-cr2` - 200GBASE-CR2 (200GE)\n* `200gbase-cr4` - 200GBASE-CR4 (200GE)\n* `200gbase-dr4` - 200GBASE-DR4 (200GE)\n* `200gbase-er4` - 200GBASE-ER4 (200GE)\n* `200gbase-fr4` - 200GBASE-FR4 (200GE)\n* `200gbase-lr4` - 200GBASE-LR4 (200GE)\n* `200gbase-sr2` - 200GBASE-SR2 (200GE)\n* `200gbase-sr4` - 200GBASE-SR4 (200GE)\n* `200gbase-vr2` - 200GBASE-VR2 (200GE)\n* `400gbase-cr4` - 400GBASE-CR4 (400GE)\n* `400gbase-dr4` - 400GBASE-DR4 (400GE)\n* `400gbase-er8` - 400GBASE-ER8 (400GE)\n* `400gbase-fr4` - 400GBASE-FR4 (400GE)\n* `400gbase-fr8` - 400GBASE-FR8 (400GE)\n* `400gbase-lr4` - 400GBASE-LR4 (400GE)\n* `400gbase-lr8` - 400GBASE-LR8 (400GE)\n* `400gbase-sr4` - 400GBASE-SR4 (400GE)\n* `400gbase-sr4_2` - 400GBASE-SR4.2 (400GE BiDi)\n* `400gbase-sr8` - 400GBASE-SR8 (400GE)\n* `400gbase-sr16` - 400GBASE-SR16 (400GE)\n* `400gbase-vr4` - 400GBASE-VR4 (400GE)\n* `400gbase-zr` - 400GBASE-ZR (400GE)\n* `800gbase-cr8` - 800GBASE-CR8 (800GE)\n* `800gbase-dr8` - 800GBASE-DR8 (800GE)\n* `800gbase-sr8` - 800GBASE-SR8 (800GE)\n* `800gbase-vr8` - 800GBASE-VR8 (800GE)\n* `1.6tbase-cr8` - 1.6TBASE-CR8 (1.6TE)\n* `1.6tbase-dr8` - 1.6TBASE-DR8 (1.6TE)\n* `1.6tbase-dr8-2` - 1.6TBASE-DR8-2 (1.6TE)\n* `100base-x-sfp` - SFP (100ME)\n* `1000base-x-gbic` - GBIC (1GE)\n* `1000base-x-sfp` - SFP (1GE)\n* `2.5gbase-x-sfp` - SFP (2.5GE)\n* `10gbase-x-sfpp` - SFP+ (10GE)\n* `10gbase-x-xenpak` - XENPAK (10GE)\n* `10gbase-x-xfp` - XFP (10GE)\n* `10gbase-x-x2` - X2 (10GE)\n* `25gbase-x-sfp28` - SFP28 (25GE)\n* `40gbase-x-qsfpp` - QSFP+ (40GE)\n* `50gbase-x-sfp28` - QSFP28 (50GE)\n* `50gbase-x-sfp56` - SFP56 (50GE)\n* `100gbase-x-cfp` - CFP (100GE)\n* `100gbase-x-cfp2` - CFP2 (100GE)\n* `100gbase-x-cfp4` - CFP4 (100GE)\n* `100gbase-x-cxp` - CXP (100GE)\n* `100gbase-x-cpak` - Cisco CPAK (100GE)\n* `100gbase-x-dsfp` - DSFP (100GE)\n* `100gbase-x-qsfp28` - QSFP28 (100GE)\n* `100gbase-x-qsfpdd` - QSFP-DD (100GE)\n* `100gbase-x-sfp112` - SFP112 (100GE)\n* `100gbase-x-sfpdd` - SFP-DD (100GE)\n* `200gbase-x-cfp2` - CFP2 (200GE)\n* `200gbase-x-qsfp56` - QSFP56 (200GE)\n* `200gbase-x-qsfpdd` - QSFP-DD (200GE)\n* `400gbase-x-qsfp112` - QSFP112 (400GE)\n* `400gbase-x-qsfpdd` - QSFP-DD (400GE)\n* `400gbase-x-cdfp` - CDFP (400GE)\n* `400gbase-x-cfp2` - CFP2 (400GE)\n* `400gbase-x-cfp8` - CPF8 (400GE)\n* `400gbase-x-osfp` - OSFP (400GE)\n* `400gbase-x-osfp-rhs` - OSFP-RHS (400GE)\n* `800gbase-x-osfp` - OSFP (800GE)\n* `800gbase-x-qsfpdd` - QSFP-DD (800GE)\n* `1.6tbase-x-osfp1600` - OSFP1600 (1.6TE)\n* `1.6tbase-x-osfp1600-rhs` - OSFP1600-RHS (1.6TE)\n* `1.6tbase-x-qsfpdd1600` - QSFP-DD1600 (1.6TE)\n* `1000base-kx` - 1000BASE-KX (1GE)\n* `2.5gbase-kx` - 2.5GBASE-KX (2.5GE)\n* `5gbase-kr` - 5GBASE-KR (5GE)\n* `10gbase-kr` - 10GBASE-KR (10GE)\n* `10gbase-kx4` - 10GBASE-KX4 (10GE)\n* `25gbase-kr` - 25GBASE-KR (25GE)\n* `40gbase-kr4` - 40GBASE-KR4 (40GE)\n* `50gbase-kr` - 50GBASE-KR (50GE)\n* `100gbase-kp4` - 100GBASE-KP4 (100GE)\n* `100gbase-kr2` - 100GBASE-KR2 (100GE)\n* `100gbase-kr4` - 100GBASE-KR4 (100GE)\n* `1.6tbase-kr8` - 1.6TBASE-KR8 (1.6TE)\n* `ieee802.11a` - IEEE 802.11a\n* `ieee802.11g` - IEEE 802.11b/g\n* `ieee802.11n` - IEEE 802.11n (Wi-Fi 4)\n* `ieee802.11ac` - IEEE 802.11ac (Wi-Fi 5)\n* `ieee802.11ad` - IEEE 802.11ad (WiGig)\n* `ieee802.11ax` - IEEE 802.11ax (Wi-Fi 6)\n* `ieee802.11ay` - IEEE 802.11ay (WiGig)\n* `ieee802.11be` - IEEE 802.11be (Wi-Fi 7)\n* `ieee802.15.1` - IEEE 802.15.1 (Bluetooth)\n* `ieee802.15.4` - IEEE 802.15.4 (LR-WPAN)\n* `other-wireless` - Other (Wireless)\n* `gsm` - GSM\n* `cdma` - CDMA\n* `lte` - LTE\n* `4g` - 4G\n* `5g` - 5G\n* `sonet-oc3` - OC-3/STM-1\n* `sonet-oc12` - OC-12/STM-4\n* `sonet-oc48` - OC-48/STM-16\n* `sonet-oc192` - OC-192/STM-64\n* `sonet-oc768` - OC-768/STM-256\n* `sonet-oc1920` - OC-1920/STM-640\n* `sonet-oc3840` - OC-3840/STM-1234\n* `1gfc-sfp` - SFP (1GFC)\n* `2gfc-sfp` - SFP (2GFC)\n* `4gfc-sfp` - SFP (4GFC)\n* `8gfc-sfpp` - SFP+ (8GFC)\n* `16gfc-sfpp` - SFP+ (16GFC)\n* `32gfc-sfp28` - SFP28 (32GFC)\n* `32gfc-sfpp` - SFP+ (32GFC)\n* `64gfc-qsfpp` - QSFP+ (64GFC)\n* `64gfc-sfpdd` - SFP-DD (64GFC)\n* `64gfc-sfpp` - SFP+ (64GFC)\n* `128gfc-qsfp28` - QSFP28 (128GFC)\n* `infiniband-sdr` - SDR (2 Gbps)\n* `infiniband-ddr` - DDR (4 Gbps)\n* `infiniband-qdr` - QDR (8 Gbps)\n* `infiniband-fdr10` - FDR10 (10 Gbps)\n* `infiniband-fdr` - FDR (13.5 Gbps)\n* `infiniband-edr` - EDR (25 Gbps)\n* `infiniband-hdr` - HDR (50 Gbps)\n* `infiniband-ndr` - NDR (100 Gbps)\n* `infiniband-xdr` - XDR (200 Gbps)\n* `infiniband-sdr-4x` - SDR 4X (8 Gbps)\n* `infiniband-ddr-4x` - DDR 4X (16 Gbps)\n* `infiniband-qdr-4x` - QDR 4X (32 Gbps)\n* `infiniband-fdr10-4x` - FDR10 4X (40 Gbps)\n* `infiniband-fdr-4x` - FDR 4X (56 Gbps)\n* `infiniband-edr-4x` - EDR 4X (100 Gbps)\n* `infiniband-hdr-4x` - HDR 4X (200 Gbps)\n* `infiniband-ndr-4x` - NDR 4X (400 Gbps)\n* `infiniband-xdr-4x` - XDR 4X (800 Gbps)\n* `t1` - T1 (1.544 Mbps)\n* `e1` - E1 (2.048 Mbps)\n* `t3` - T3 (45 Mbps)\n* `e3` - E3 (34 Mbps)\n* `xdsl` - xDSL\n* `docsis` - DOCSIS\n* `moca` - MoCA\n* `bpon` - BPON (622 Mbps / 155 Mbps)\n* `epon` - EPON (1 Gbps)\n* `10g-epon` - 10G-EPON (10 Gbps)\n* `gpon` - GPON (2.5 Gbps / 1.25 Gbps)\n* `xg-pon` - XG-PON (10 Gbps / 2.5 Gbps)\n* `xgs-pon` - XGS-PON (10 Gbps)\n* `ng-pon2` - NG-PON2 (TWDM-PON) (4x10 Gbps)\n* `25g-pon` - 25G-PON (25 Gbps)\n* `50g-pon` - 50G-PON (50 Gbps)\n* `cisco-stackwise` - Cisco StackWise\n* `cisco-stackwise-plus` - Cisco StackWise Plus\n* `cisco-flexstack` - Cisco FlexStack\n* `cisco-flexstack-plus` - Cisco FlexStack Plus\n* `cisco-stackwise-80` - Cisco StackWise-80\n* `cisco-stackwise-160` - Cisco StackWise-160\n* `cisco-stackwise-320` - Cisco StackWise-320\n* `cisco-stackwise-480` - Cisco StackWise-480\n* `cisco-stackwise-1t` - Cisco StackWise-1T\n* `juniper-vcp` - Juniper VCP\n* `extreme-summitstack` - Extreme SummitStack\n* `extreme-summitstack-128` - Extreme SummitStack-128\n* `extreme-summitstack-256` - Extreme SummitStack-256\n* `extreme-summitstack-512` - Extreme SummitStack-512\n* `hpe-synergy-interconnect-link` - HPE Synergy Interconnect Link\n* `other` - Other", + "x-spec-enum-id": "19cc901fcea417ad" }, "label": { "type": "string", @@ -270648,6 +303938,7 @@ "Virtual", "Bridge", "Link Aggregation Group (LAG)", + "Channel", "100BASE-FX (10/100ME)", "100BASE-LFX (10/100ME)", "100BASE-TX (10/100ME)", @@ -270876,6 +304167,20 @@ } } }, + "channels": { + "type": "integer", + "maximum": 1024, + "minimum": 1, + "nullable": true, + "description": "The number of channels into which this interface is channelized" + }, + "channel_id": { + "type": "integer", + "maximum": 1024, + "minimum": 1, + "nullable": true, + "description": "The channel on the parent interface to which this subinterface is bound" + }, "enabled": { "type": "boolean" }, @@ -270918,7 +304223,6 @@ }, "mac_address": { "type": "string", - "readOnly": true, "nullable": true }, "primary_mac_address": { @@ -271705,7 +305009,6 @@ "last_updated", "link_peers", "link_peers_type", - "mac_address", "mac_addresses", "name", "type", @@ -271715,7 +305018,7 @@ }, "InterfaceRequest": { "type": "object", - "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", + "description": "Mixin for Interface and VMInterface serializers that adds a `mac_address` shortcut field for\ncreating/updating the primary MACAddress in a single request. The validated write is centralized\non the interface model (BaseInterface.set_primary_mac_address); this mixin is a thin adapter that\nowns the permission check, the shortcut-vs-primary_mac_address conflict guard, and translating the\nmodel's validation errors into API errors.", "properties": { "device": { "oneOf": [ @@ -271764,6 +305067,7 @@ "virtual", "bridge", "lag", + "channel", "100base-fx", "100base-lfx", "100base-tx", @@ -271990,8 +305294,22 @@ "other" ], "type": "string", - "description": "* `virtual` - Virtual\n* `bridge` - Bridge\n* `lag` - Link Aggregation Group (LAG)\n* `100base-fx` - 100BASE-FX (10/100ME)\n* `100base-lfx` - 100BASE-LFX (10/100ME)\n* `100base-tx` - 100BASE-TX (10/100ME)\n* `100base-t1` - 100BASE-T1 (10/100ME)\n* `1000base-bx10-d` - 1000BASE-BX10-D (1GE BiDi Down)\n* `1000base-bx10-u` - 1000BASE-BX10-U (1GE BiDi Up)\n* `1000base-cwdm` - 1000BASE-CWDM (1GE)\n* `1000base-cx` - 1000BASE-CX (1GE DAC)\n* `1000base-dwdm` - 1000BASE-DWDM (1GE)\n* `1000base-ex` - 1000BASE-EX (1GE)\n* `1000base-lsx` - 1000BASE-LSX (1GE)\n* `1000base-lx` - 1000BASE-LX (1GE)\n* `1000base-lx10` - 1000BASE-LX10/LH (1GE)\n* `1000base-sx` - 1000BASE-SX (1GE)\n* `1000base-t` - 1000BASE-T (1GE)\n* `1000base-tx` - 1000BASE-TX (1GE)\n* `1000base-zx` - 1000BASE-ZX (1GE)\n* `2.5gbase-t` - 2.5GBASE-T (2.5GE)\n* `5gbase-t` - 5GBASE-T (5GE)\n* `10gbase-br-d` - 10GBASE-BR-D (10GE BiDi Down)\n* `10gbase-br-u` - 10GBASE-BR-U (10GE BiDi Up)\n* `10gbase-cu` - 10GBASE-CU (10GE DAC Passive Twinax)\n* `10gbase-cx4` - 10GBASE-CX4 (10GE DAC)\n* `10gbase-er` - 10GBASE-ER (10GE)\n* `10gbase-lr` - 10GBASE-LR (10GE)\n* `10gbase-lrm` - 10GBASE-LRM (10GE)\n* `10gbase-lx4` - 10GBASE-LX4 (10GE)\n* `10gbase-sr` - 10GBASE-SR (10GE)\n* `10gbase-t` - 10GBASE-T (10GE)\n* `10gbase-zr` - 10GBASE-ZR (10GE)\n* `25gbase-cr` - 25GBASE-CR (25GE DAC)\n* `25gbase-er` - 25GBASE-ER (25GE)\n* `25gbase-lr` - 25GBASE-LR (25GE)\n* `25gbase-sr` - 25GBASE-SR (25GE)\n* `25gbase-t` - 25GBASE-T (25GE)\n* `40gbase-cr4` - 40GBASE-CR4 (40GE DAC)\n* `40gbase-er4` - 40GBASE-ER4 (40GE)\n* `40gbase-fr4` - 40GBASE-FR4 (40GE)\n* `40gbase-lr4` - 40GBASE-LR4 (40GE)\n* `40gbase-sr4` - 40GBASE-SR4 (40GE)\n* `40gbase-sr4-bd` - 40GBASE-SR4 (40GE BiDi)\n* `50gbase-cr` - 50GBASE-CR (50GE DAC)\n* `50gbase-er` - 50GBASE-ER (50GE)\n* `50gbase-fr` - 50GBASE-FR (50GE)\n* `50gbase-lr` - 50GBASE-LR (50GE)\n* `50gbase-sr` - 50GBASE-SR (50GE)\n* `100gbase-cr1` - 100GBASE-CR1 (100GE DAC)\n* `100gbase-cr2` - 100GBASE-CR2 (100GE DAC)\n* `100gbase-cr4` - 100GBASE-CR4 (100GE DAC)\n* `100gbase-cr10` - 100GBASE-CR10 (100GE DAC)\n* `100gbase-cwdm4` - 100GBASE-CWDM4 (100GE)\n* `100gbase-dr` - 100GBASE-DR (100GE)\n* `100gbase-er4` - 100GBASE-ER4 (100GE)\n* `100gbase-fr1` - 100GBASE-FR1 (100GE)\n* `100gbase-lr1` - 100GBASE-LR1 (100GE)\n* `100gbase-lr4` - 100GBASE-LR4 (100GE)\n* `100gbase-sr1` - 100GBASE-SR1 (100GE)\n* `100gbase-sr1.2` - 100GBASE-SR1.2 (100GE BiDi)\n* `100gbase-sr2` - 100GBASE-SR2 (100GE)\n* `100gbase-sr4` - 100GBASE-SR4 (100GE)\n* `100gbase-sr10` - 100GBASE-SR10 (100GE)\n* `100gbase-zr` - 100GBASE-ZR (100GE)\n* `200gbase-cr2` - 200GBASE-CR2 (200GE)\n* `200gbase-cr4` - 200GBASE-CR4 (200GE)\n* `200gbase-dr4` - 200GBASE-DR4 (200GE)\n* `200gbase-er4` - 200GBASE-ER4 (200GE)\n* `200gbase-fr4` - 200GBASE-FR4 (200GE)\n* `200gbase-lr4` - 200GBASE-LR4 (200GE)\n* `200gbase-sr2` - 200GBASE-SR2 (200GE)\n* `200gbase-sr4` - 200GBASE-SR4 (200GE)\n* `200gbase-vr2` - 200GBASE-VR2 (200GE)\n* `400gbase-cr4` - 400GBASE-CR4 (400GE)\n* `400gbase-dr4` - 400GBASE-DR4 (400GE)\n* `400gbase-er8` - 400GBASE-ER8 (400GE)\n* `400gbase-fr4` - 400GBASE-FR4 (400GE)\n* `400gbase-fr8` - 400GBASE-FR8 (400GE)\n* `400gbase-lr4` - 400GBASE-LR4 (400GE)\n* `400gbase-lr8` - 400GBASE-LR8 (400GE)\n* `400gbase-sr4` - 400GBASE-SR4 (400GE)\n* `400gbase-sr4_2` - 400GBASE-SR4.2 (400GE BiDi)\n* `400gbase-sr8` - 400GBASE-SR8 (400GE)\n* `400gbase-sr16` - 400GBASE-SR16 (400GE)\n* `400gbase-vr4` - 400GBASE-VR4 (400GE)\n* `400gbase-zr` - 400GBASE-ZR (400GE)\n* `800gbase-cr8` - 800GBASE-CR8 (800GE)\n* `800gbase-dr8` - 800GBASE-DR8 (800GE)\n* `800gbase-sr8` - 800GBASE-SR8 (800GE)\n* `800gbase-vr8` - 800GBASE-VR8 (800GE)\n* `1.6tbase-cr8` - 1.6TBASE-CR8 (1.6TE)\n* `1.6tbase-dr8` - 1.6TBASE-DR8 (1.6TE)\n* `1.6tbase-dr8-2` - 1.6TBASE-DR8-2 (1.6TE)\n* `100base-x-sfp` - SFP (100ME)\n* `1000base-x-gbic` - GBIC (1GE)\n* `1000base-x-sfp` - SFP (1GE)\n* `2.5gbase-x-sfp` - SFP (2.5GE)\n* `10gbase-x-sfpp` - SFP+ (10GE)\n* `10gbase-x-xenpak` - XENPAK (10GE)\n* `10gbase-x-xfp` - XFP (10GE)\n* `10gbase-x-x2` - X2 (10GE)\n* `25gbase-x-sfp28` - SFP28 (25GE)\n* `40gbase-x-qsfpp` - QSFP+ (40GE)\n* `50gbase-x-sfp28` - QSFP28 (50GE)\n* `50gbase-x-sfp56` - SFP56 (50GE)\n* `100gbase-x-cfp` - CFP (100GE)\n* `100gbase-x-cfp2` - CFP2 (100GE)\n* `100gbase-x-cfp4` - CFP4 (100GE)\n* `100gbase-x-cxp` - CXP (100GE)\n* `100gbase-x-cpak` - Cisco CPAK (100GE)\n* `100gbase-x-dsfp` - DSFP (100GE)\n* `100gbase-x-qsfp28` - QSFP28 (100GE)\n* `100gbase-x-qsfpdd` - QSFP-DD (100GE)\n* `100gbase-x-sfp112` - SFP112 (100GE)\n* `100gbase-x-sfpdd` - SFP-DD (100GE)\n* `200gbase-x-cfp2` - CFP2 (200GE)\n* `200gbase-x-qsfp56` - QSFP56 (200GE)\n* `200gbase-x-qsfpdd` - QSFP-DD (200GE)\n* `400gbase-x-qsfp112` - QSFP112 (400GE)\n* `400gbase-x-qsfpdd` - QSFP-DD (400GE)\n* `400gbase-x-cdfp` - CDFP (400GE)\n* `400gbase-x-cfp2` - CFP2 (400GE)\n* `400gbase-x-cfp8` - CPF8 (400GE)\n* `400gbase-x-osfp` - OSFP (400GE)\n* `400gbase-x-osfp-rhs` - OSFP-RHS (400GE)\n* `800gbase-x-osfp` - OSFP (800GE)\n* `800gbase-x-qsfpdd` - QSFP-DD (800GE)\n* `1.6tbase-x-osfp1600` - OSFP1600 (1.6TE)\n* `1.6tbase-x-osfp1600-rhs` - OSFP1600-RHS (1.6TE)\n* `1.6tbase-x-qsfpdd1600` - QSFP-DD1600 (1.6TE)\n* `1000base-kx` - 1000BASE-KX (1GE)\n* `2.5gbase-kx` - 2.5GBASE-KX (2.5GE)\n* `5gbase-kr` - 5GBASE-KR (5GE)\n* `10gbase-kr` - 10GBASE-KR (10GE)\n* `10gbase-kx4` - 10GBASE-KX4 (10GE)\n* `25gbase-kr` - 25GBASE-KR (25GE)\n* `40gbase-kr4` - 40GBASE-KR4 (40GE)\n* `50gbase-kr` - 50GBASE-KR (50GE)\n* `100gbase-kp4` - 100GBASE-KP4 (100GE)\n* `100gbase-kr2` - 100GBASE-KR2 (100GE)\n* `100gbase-kr4` - 100GBASE-KR4 (100GE)\n* `1.6tbase-kr8` - 1.6TBASE-KR8 (1.6TE)\n* `ieee802.11a` - IEEE 802.11a\n* `ieee802.11g` - IEEE 802.11b/g\n* `ieee802.11n` - IEEE 802.11n (Wi-Fi 4)\n* `ieee802.11ac` - IEEE 802.11ac (Wi-Fi 5)\n* `ieee802.11ad` - IEEE 802.11ad (WiGig)\n* `ieee802.11ax` - IEEE 802.11ax (Wi-Fi 6)\n* `ieee802.11ay` - IEEE 802.11ay (WiGig)\n* `ieee802.11be` - IEEE 802.11be (Wi-Fi 7)\n* `ieee802.15.1` - IEEE 802.15.1 (Bluetooth)\n* `ieee802.15.4` - IEEE 802.15.4 (LR-WPAN)\n* `other-wireless` - Other (Wireless)\n* `gsm` - GSM\n* `cdma` - CDMA\n* `lte` - LTE\n* `4g` - 4G\n* `5g` - 5G\n* `sonet-oc3` - OC-3/STM-1\n* `sonet-oc12` - OC-12/STM-4\n* `sonet-oc48` - OC-48/STM-16\n* `sonet-oc192` - OC-192/STM-64\n* `sonet-oc768` - OC-768/STM-256\n* `sonet-oc1920` - OC-1920/STM-640\n* `sonet-oc3840` - OC-3840/STM-1234\n* `1gfc-sfp` - SFP (1GFC)\n* `2gfc-sfp` - SFP (2GFC)\n* `4gfc-sfp` - SFP (4GFC)\n* `8gfc-sfpp` - SFP+ (8GFC)\n* `16gfc-sfpp` - SFP+ (16GFC)\n* `32gfc-sfp28` - SFP28 (32GFC)\n* `32gfc-sfpp` - SFP+ (32GFC)\n* `64gfc-qsfpp` - QSFP+ (64GFC)\n* `64gfc-sfpdd` - SFP-DD (64GFC)\n* `64gfc-sfpp` - SFP+ (64GFC)\n* `128gfc-qsfp28` - QSFP28 (128GFC)\n* `infiniband-sdr` - SDR (2 Gbps)\n* `infiniband-ddr` - DDR (4 Gbps)\n* `infiniband-qdr` - QDR (8 Gbps)\n* `infiniband-fdr10` - FDR10 (10 Gbps)\n* `infiniband-fdr` - FDR (13.5 Gbps)\n* `infiniband-edr` - EDR (25 Gbps)\n* `infiniband-hdr` - HDR (50 Gbps)\n* `infiniband-ndr` - NDR (100 Gbps)\n* `infiniband-xdr` - XDR (200 Gbps)\n* `infiniband-sdr-4x` - SDR 4X (8 Gbps)\n* `infiniband-ddr-4x` - DDR 4X (16 Gbps)\n* `infiniband-qdr-4x` - QDR 4X (32 Gbps)\n* `infiniband-fdr10-4x` - FDR10 4X (40 Gbps)\n* `infiniband-fdr-4x` - FDR 4X (56 Gbps)\n* `infiniband-edr-4x` - EDR 4X (100 Gbps)\n* `infiniband-hdr-4x` - HDR 4X (200 Gbps)\n* `infiniband-ndr-4x` - NDR 4X (400 Gbps)\n* `infiniband-xdr-4x` - XDR 4X (800 Gbps)\n* `t1` - T1 (1.544 Mbps)\n* `e1` - E1 (2.048 Mbps)\n* `t3` - T3 (45 Mbps)\n* `e3` - E3 (34 Mbps)\n* `xdsl` - xDSL\n* `docsis` - DOCSIS\n* `moca` - MoCA\n* `bpon` - BPON (622 Mbps / 155 Mbps)\n* `epon` - EPON (1 Gbps)\n* `10g-epon` - 10G-EPON (10 Gbps)\n* `gpon` - GPON (2.5 Gbps / 1.25 Gbps)\n* `xg-pon` - XG-PON (10 Gbps / 2.5 Gbps)\n* `xgs-pon` - XGS-PON (10 Gbps)\n* `ng-pon2` - NG-PON2 (TWDM-PON) (4x10 Gbps)\n* `25g-pon` - 25G-PON (25 Gbps)\n* `50g-pon` - 50G-PON (50 Gbps)\n* `cisco-stackwise` - Cisco StackWise\n* `cisco-stackwise-plus` - Cisco StackWise Plus\n* `cisco-flexstack` - Cisco FlexStack\n* `cisco-flexstack-plus` - Cisco FlexStack Plus\n* `cisco-stackwise-80` - Cisco StackWise-80\n* `cisco-stackwise-160` - Cisco StackWise-160\n* `cisco-stackwise-320` - Cisco StackWise-320\n* `cisco-stackwise-480` - Cisco StackWise-480\n* `cisco-stackwise-1t` - Cisco StackWise-1T\n* `juniper-vcp` - Juniper VCP\n* `extreme-summitstack` - Extreme SummitStack\n* `extreme-summitstack-128` - Extreme SummitStack-128\n* `extreme-summitstack-256` - Extreme SummitStack-256\n* `extreme-summitstack-512` - Extreme SummitStack-512\n* `hpe-synergy-interconnect-link` - HPE Synergy Interconnect Link\n* `other` - Other", - "x-spec-enum-id": "b0c97040e5abdff1" + "description": "* `virtual` - Virtual\n* `bridge` - Bridge\n* `lag` - Link Aggregation Group (LAG)\n* `channel` - Channel\n* `100base-fx` - 100BASE-FX (10/100ME)\n* `100base-lfx` - 100BASE-LFX (10/100ME)\n* `100base-tx` - 100BASE-TX (10/100ME)\n* `100base-t1` - 100BASE-T1 (10/100ME)\n* `1000base-bx10-d` - 1000BASE-BX10-D (1GE BiDi Down)\n* `1000base-bx10-u` - 1000BASE-BX10-U (1GE BiDi Up)\n* `1000base-cwdm` - 1000BASE-CWDM (1GE)\n* `1000base-cx` - 1000BASE-CX (1GE DAC)\n* `1000base-dwdm` - 1000BASE-DWDM (1GE)\n* `1000base-ex` - 1000BASE-EX (1GE)\n* `1000base-lsx` - 1000BASE-LSX (1GE)\n* `1000base-lx` - 1000BASE-LX (1GE)\n* `1000base-lx10` - 1000BASE-LX10/LH (1GE)\n* `1000base-sx` - 1000BASE-SX (1GE)\n* `1000base-t` - 1000BASE-T (1GE)\n* `1000base-tx` - 1000BASE-TX (1GE)\n* `1000base-zx` - 1000BASE-ZX (1GE)\n* `2.5gbase-t` - 2.5GBASE-T (2.5GE)\n* `5gbase-t` - 5GBASE-T (5GE)\n* `10gbase-br-d` - 10GBASE-BR-D (10GE BiDi Down)\n* `10gbase-br-u` - 10GBASE-BR-U (10GE BiDi Up)\n* `10gbase-cu` - 10GBASE-CU (10GE DAC Passive Twinax)\n* `10gbase-cx4` - 10GBASE-CX4 (10GE DAC)\n* `10gbase-er` - 10GBASE-ER (10GE)\n* `10gbase-lr` - 10GBASE-LR (10GE)\n* `10gbase-lrm` - 10GBASE-LRM (10GE)\n* `10gbase-lx4` - 10GBASE-LX4 (10GE)\n* `10gbase-sr` - 10GBASE-SR (10GE)\n* `10gbase-t` - 10GBASE-T (10GE)\n* `10gbase-zr` - 10GBASE-ZR (10GE)\n* `25gbase-cr` - 25GBASE-CR (25GE DAC)\n* `25gbase-er` - 25GBASE-ER (25GE)\n* `25gbase-lr` - 25GBASE-LR (25GE)\n* `25gbase-sr` - 25GBASE-SR (25GE)\n* `25gbase-t` - 25GBASE-T (25GE)\n* `40gbase-cr4` - 40GBASE-CR4 (40GE DAC)\n* `40gbase-er4` - 40GBASE-ER4 (40GE)\n* `40gbase-fr4` - 40GBASE-FR4 (40GE)\n* `40gbase-lr4` - 40GBASE-LR4 (40GE)\n* `40gbase-sr4` - 40GBASE-SR4 (40GE)\n* `40gbase-sr4-bd` - 40GBASE-SR4 (40GE BiDi)\n* `50gbase-cr` - 50GBASE-CR (50GE DAC)\n* `50gbase-er` - 50GBASE-ER (50GE)\n* `50gbase-fr` - 50GBASE-FR (50GE)\n* `50gbase-lr` - 50GBASE-LR (50GE)\n* `50gbase-sr` - 50GBASE-SR (50GE)\n* `100gbase-cr1` - 100GBASE-CR1 (100GE DAC)\n* `100gbase-cr2` - 100GBASE-CR2 (100GE DAC)\n* `100gbase-cr4` - 100GBASE-CR4 (100GE DAC)\n* `100gbase-cr10` - 100GBASE-CR10 (100GE DAC)\n* `100gbase-cwdm4` - 100GBASE-CWDM4 (100GE)\n* `100gbase-dr` - 100GBASE-DR (100GE)\n* `100gbase-er4` - 100GBASE-ER4 (100GE)\n* `100gbase-fr1` - 100GBASE-FR1 (100GE)\n* `100gbase-lr1` - 100GBASE-LR1 (100GE)\n* `100gbase-lr4` - 100GBASE-LR4 (100GE)\n* `100gbase-sr1` - 100GBASE-SR1 (100GE)\n* `100gbase-sr1.2` - 100GBASE-SR1.2 (100GE BiDi)\n* `100gbase-sr2` - 100GBASE-SR2 (100GE)\n* `100gbase-sr4` - 100GBASE-SR4 (100GE)\n* `100gbase-sr10` - 100GBASE-SR10 (100GE)\n* `100gbase-zr` - 100GBASE-ZR (100GE)\n* `200gbase-cr2` - 200GBASE-CR2 (200GE)\n* `200gbase-cr4` - 200GBASE-CR4 (200GE)\n* `200gbase-dr4` - 200GBASE-DR4 (200GE)\n* `200gbase-er4` - 200GBASE-ER4 (200GE)\n* `200gbase-fr4` - 200GBASE-FR4 (200GE)\n* `200gbase-lr4` - 200GBASE-LR4 (200GE)\n* `200gbase-sr2` - 200GBASE-SR2 (200GE)\n* `200gbase-sr4` - 200GBASE-SR4 (200GE)\n* `200gbase-vr2` - 200GBASE-VR2 (200GE)\n* `400gbase-cr4` - 400GBASE-CR4 (400GE)\n* `400gbase-dr4` - 400GBASE-DR4 (400GE)\n* `400gbase-er8` - 400GBASE-ER8 (400GE)\n* `400gbase-fr4` - 400GBASE-FR4 (400GE)\n* `400gbase-fr8` - 400GBASE-FR8 (400GE)\n* `400gbase-lr4` - 400GBASE-LR4 (400GE)\n* `400gbase-lr8` - 400GBASE-LR8 (400GE)\n* `400gbase-sr4` - 400GBASE-SR4 (400GE)\n* `400gbase-sr4_2` - 400GBASE-SR4.2 (400GE BiDi)\n* `400gbase-sr8` - 400GBASE-SR8 (400GE)\n* `400gbase-sr16` - 400GBASE-SR16 (400GE)\n* `400gbase-vr4` - 400GBASE-VR4 (400GE)\n* `400gbase-zr` - 400GBASE-ZR (400GE)\n* `800gbase-cr8` - 800GBASE-CR8 (800GE)\n* `800gbase-dr8` - 800GBASE-DR8 (800GE)\n* `800gbase-sr8` - 800GBASE-SR8 (800GE)\n* `800gbase-vr8` - 800GBASE-VR8 (800GE)\n* `1.6tbase-cr8` - 1.6TBASE-CR8 (1.6TE)\n* `1.6tbase-dr8` - 1.6TBASE-DR8 (1.6TE)\n* `1.6tbase-dr8-2` - 1.6TBASE-DR8-2 (1.6TE)\n* `100base-x-sfp` - SFP (100ME)\n* `1000base-x-gbic` - GBIC (1GE)\n* `1000base-x-sfp` - SFP (1GE)\n* `2.5gbase-x-sfp` - SFP (2.5GE)\n* `10gbase-x-sfpp` - SFP+ (10GE)\n* `10gbase-x-xenpak` - XENPAK (10GE)\n* `10gbase-x-xfp` - XFP (10GE)\n* `10gbase-x-x2` - X2 (10GE)\n* `25gbase-x-sfp28` - SFP28 (25GE)\n* `40gbase-x-qsfpp` - QSFP+ (40GE)\n* `50gbase-x-sfp28` - QSFP28 (50GE)\n* `50gbase-x-sfp56` - SFP56 (50GE)\n* `100gbase-x-cfp` - CFP (100GE)\n* `100gbase-x-cfp2` - CFP2 (100GE)\n* `100gbase-x-cfp4` - CFP4 (100GE)\n* `100gbase-x-cxp` - CXP (100GE)\n* `100gbase-x-cpak` - Cisco CPAK (100GE)\n* `100gbase-x-dsfp` - DSFP (100GE)\n* `100gbase-x-qsfp28` - QSFP28 (100GE)\n* `100gbase-x-qsfpdd` - QSFP-DD (100GE)\n* `100gbase-x-sfp112` - SFP112 (100GE)\n* `100gbase-x-sfpdd` - SFP-DD (100GE)\n* `200gbase-x-cfp2` - CFP2 (200GE)\n* `200gbase-x-qsfp56` - QSFP56 (200GE)\n* `200gbase-x-qsfpdd` - QSFP-DD (200GE)\n* `400gbase-x-qsfp112` - QSFP112 (400GE)\n* `400gbase-x-qsfpdd` - QSFP-DD (400GE)\n* `400gbase-x-cdfp` - CDFP (400GE)\n* `400gbase-x-cfp2` - CFP2 (400GE)\n* `400gbase-x-cfp8` - CPF8 (400GE)\n* `400gbase-x-osfp` - OSFP (400GE)\n* `400gbase-x-osfp-rhs` - OSFP-RHS (400GE)\n* `800gbase-x-osfp` - OSFP (800GE)\n* `800gbase-x-qsfpdd` - QSFP-DD (800GE)\n* `1.6tbase-x-osfp1600` - OSFP1600 (1.6TE)\n* `1.6tbase-x-osfp1600-rhs` - OSFP1600-RHS (1.6TE)\n* `1.6tbase-x-qsfpdd1600` - QSFP-DD1600 (1.6TE)\n* `1000base-kx` - 1000BASE-KX (1GE)\n* `2.5gbase-kx` - 2.5GBASE-KX (2.5GE)\n* `5gbase-kr` - 5GBASE-KR (5GE)\n* `10gbase-kr` - 10GBASE-KR (10GE)\n* `10gbase-kx4` - 10GBASE-KX4 (10GE)\n* `25gbase-kr` - 25GBASE-KR (25GE)\n* `40gbase-kr4` - 40GBASE-KR4 (40GE)\n* `50gbase-kr` - 50GBASE-KR (50GE)\n* `100gbase-kp4` - 100GBASE-KP4 (100GE)\n* `100gbase-kr2` - 100GBASE-KR2 (100GE)\n* `100gbase-kr4` - 100GBASE-KR4 (100GE)\n* `1.6tbase-kr8` - 1.6TBASE-KR8 (1.6TE)\n* `ieee802.11a` - IEEE 802.11a\n* `ieee802.11g` - IEEE 802.11b/g\n* `ieee802.11n` - IEEE 802.11n (Wi-Fi 4)\n* `ieee802.11ac` - IEEE 802.11ac (Wi-Fi 5)\n* `ieee802.11ad` - IEEE 802.11ad (WiGig)\n* `ieee802.11ax` - IEEE 802.11ax (Wi-Fi 6)\n* `ieee802.11ay` - IEEE 802.11ay (WiGig)\n* `ieee802.11be` - IEEE 802.11be (Wi-Fi 7)\n* `ieee802.15.1` - IEEE 802.15.1 (Bluetooth)\n* `ieee802.15.4` - IEEE 802.15.4 (LR-WPAN)\n* `other-wireless` - Other (Wireless)\n* `gsm` - GSM\n* `cdma` - CDMA\n* `lte` - LTE\n* `4g` - 4G\n* `5g` - 5G\n* `sonet-oc3` - OC-3/STM-1\n* `sonet-oc12` - OC-12/STM-4\n* `sonet-oc48` - OC-48/STM-16\n* `sonet-oc192` - OC-192/STM-64\n* `sonet-oc768` - OC-768/STM-256\n* `sonet-oc1920` - OC-1920/STM-640\n* `sonet-oc3840` - OC-3840/STM-1234\n* `1gfc-sfp` - SFP (1GFC)\n* `2gfc-sfp` - SFP (2GFC)\n* `4gfc-sfp` - SFP (4GFC)\n* `8gfc-sfpp` - SFP+ (8GFC)\n* `16gfc-sfpp` - SFP+ (16GFC)\n* `32gfc-sfp28` - SFP28 (32GFC)\n* `32gfc-sfpp` - SFP+ (32GFC)\n* `64gfc-qsfpp` - QSFP+ (64GFC)\n* `64gfc-sfpdd` - SFP-DD (64GFC)\n* `64gfc-sfpp` - SFP+ (64GFC)\n* `128gfc-qsfp28` - QSFP28 (128GFC)\n* `infiniband-sdr` - SDR (2 Gbps)\n* `infiniband-ddr` - DDR (4 Gbps)\n* `infiniband-qdr` - QDR (8 Gbps)\n* `infiniband-fdr10` - FDR10 (10 Gbps)\n* `infiniband-fdr` - FDR (13.5 Gbps)\n* `infiniband-edr` - EDR (25 Gbps)\n* `infiniband-hdr` - HDR (50 Gbps)\n* `infiniband-ndr` - NDR (100 Gbps)\n* `infiniband-xdr` - XDR (200 Gbps)\n* `infiniband-sdr-4x` - SDR 4X (8 Gbps)\n* `infiniband-ddr-4x` - DDR 4X (16 Gbps)\n* `infiniband-qdr-4x` - QDR 4X (32 Gbps)\n* `infiniband-fdr10-4x` - FDR10 4X (40 Gbps)\n* `infiniband-fdr-4x` - FDR 4X (56 Gbps)\n* `infiniband-edr-4x` - EDR 4X (100 Gbps)\n* `infiniband-hdr-4x` - HDR 4X (200 Gbps)\n* `infiniband-ndr-4x` - NDR 4X (400 Gbps)\n* `infiniband-xdr-4x` - XDR 4X (800 Gbps)\n* `t1` - T1 (1.544 Mbps)\n* `e1` - E1 (2.048 Mbps)\n* `t3` - T3 (45 Mbps)\n* `e3` - E3 (34 Mbps)\n* `xdsl` - xDSL\n* `docsis` - DOCSIS\n* `moca` - MoCA\n* `bpon` - BPON (622 Mbps / 155 Mbps)\n* `epon` - EPON (1 Gbps)\n* `10g-epon` - 10G-EPON (10 Gbps)\n* `gpon` - GPON (2.5 Gbps / 1.25 Gbps)\n* `xg-pon` - XG-PON (10 Gbps / 2.5 Gbps)\n* `xgs-pon` - XGS-PON (10 Gbps)\n* `ng-pon2` - NG-PON2 (TWDM-PON) (4x10 Gbps)\n* `25g-pon` - 25G-PON (25 Gbps)\n* `50g-pon` - 50G-PON (50 Gbps)\n* `cisco-stackwise` - Cisco StackWise\n* `cisco-stackwise-plus` - Cisco StackWise Plus\n* `cisco-flexstack` - Cisco FlexStack\n* `cisco-flexstack-plus` - Cisco FlexStack Plus\n* `cisco-stackwise-80` - Cisco StackWise-80\n* `cisco-stackwise-160` - Cisco StackWise-160\n* `cisco-stackwise-320` - Cisco StackWise-320\n* `cisco-stackwise-480` - Cisco StackWise-480\n* `cisco-stackwise-1t` - Cisco StackWise-1T\n* `juniper-vcp` - Juniper VCP\n* `extreme-summitstack` - Extreme SummitStack\n* `extreme-summitstack-128` - Extreme SummitStack-128\n* `extreme-summitstack-256` - Extreme SummitStack-256\n* `extreme-summitstack-512` - Extreme SummitStack-512\n* `hpe-synergy-interconnect-link` - HPE Synergy Interconnect Link\n* `other` - Other", + "x-spec-enum-id": "19cc901fcea417ad" + }, + "channels": { + "type": "integer", + "maximum": 1024, + "minimum": 1, + "nullable": true, + "description": "The number of channels into which this interface is channelized" + }, + "channel_id": { + "type": "integer", + "maximum": 1024, + "minimum": 1, + "nullable": true, + "description": "The channel on the parent interface to which this subinterface is bound" }, "enabled": { "type": "boolean" @@ -272026,6 +305344,11 @@ "minimum": 1, "nullable": true }, + "mac_address": { + "type": "string", + "nullable": true, + "minLength": 1 + }, "primary_mac_address": { "oneOf": [ { @@ -272522,6 +305845,7 @@ "virtual", "bridge", "lag", + "channel", "100base-fx", "100base-lfx", "100base-tx", @@ -272748,8 +306072,8 @@ "other" ], "type": "string", - "description": "* `virtual` - Virtual\n* `bridge` - Bridge\n* `lag` - Link Aggregation Group (LAG)\n* `100base-fx` - 100BASE-FX (10/100ME)\n* `100base-lfx` - 100BASE-LFX (10/100ME)\n* `100base-tx` - 100BASE-TX (10/100ME)\n* `100base-t1` - 100BASE-T1 (10/100ME)\n* `1000base-bx10-d` - 1000BASE-BX10-D (1GE BiDi Down)\n* `1000base-bx10-u` - 1000BASE-BX10-U (1GE BiDi Up)\n* `1000base-cwdm` - 1000BASE-CWDM (1GE)\n* `1000base-cx` - 1000BASE-CX (1GE DAC)\n* `1000base-dwdm` - 1000BASE-DWDM (1GE)\n* `1000base-ex` - 1000BASE-EX (1GE)\n* `1000base-lsx` - 1000BASE-LSX (1GE)\n* `1000base-lx` - 1000BASE-LX (1GE)\n* `1000base-lx10` - 1000BASE-LX10/LH (1GE)\n* `1000base-sx` - 1000BASE-SX (1GE)\n* `1000base-t` - 1000BASE-T (1GE)\n* `1000base-tx` - 1000BASE-TX (1GE)\n* `1000base-zx` - 1000BASE-ZX (1GE)\n* `2.5gbase-t` - 2.5GBASE-T (2.5GE)\n* `5gbase-t` - 5GBASE-T (5GE)\n* `10gbase-br-d` - 10GBASE-BR-D (10GE BiDi Down)\n* `10gbase-br-u` - 10GBASE-BR-U (10GE BiDi Up)\n* `10gbase-cu` - 10GBASE-CU (10GE DAC Passive Twinax)\n* `10gbase-cx4` - 10GBASE-CX4 (10GE DAC)\n* `10gbase-er` - 10GBASE-ER (10GE)\n* `10gbase-lr` - 10GBASE-LR (10GE)\n* `10gbase-lrm` - 10GBASE-LRM (10GE)\n* `10gbase-lx4` - 10GBASE-LX4 (10GE)\n* `10gbase-sr` - 10GBASE-SR (10GE)\n* `10gbase-t` - 10GBASE-T (10GE)\n* `10gbase-zr` - 10GBASE-ZR (10GE)\n* `25gbase-cr` - 25GBASE-CR (25GE DAC)\n* `25gbase-er` - 25GBASE-ER (25GE)\n* `25gbase-lr` - 25GBASE-LR (25GE)\n* `25gbase-sr` - 25GBASE-SR (25GE)\n* `25gbase-t` - 25GBASE-T (25GE)\n* `40gbase-cr4` - 40GBASE-CR4 (40GE DAC)\n* `40gbase-er4` - 40GBASE-ER4 (40GE)\n* `40gbase-fr4` - 40GBASE-FR4 (40GE)\n* `40gbase-lr4` - 40GBASE-LR4 (40GE)\n* `40gbase-sr4` - 40GBASE-SR4 (40GE)\n* `40gbase-sr4-bd` - 40GBASE-SR4 (40GE BiDi)\n* `50gbase-cr` - 50GBASE-CR (50GE DAC)\n* `50gbase-er` - 50GBASE-ER (50GE)\n* `50gbase-fr` - 50GBASE-FR (50GE)\n* `50gbase-lr` - 50GBASE-LR (50GE)\n* `50gbase-sr` - 50GBASE-SR (50GE)\n* `100gbase-cr1` - 100GBASE-CR1 (100GE DAC)\n* `100gbase-cr2` - 100GBASE-CR2 (100GE DAC)\n* `100gbase-cr4` - 100GBASE-CR4 (100GE DAC)\n* `100gbase-cr10` - 100GBASE-CR10 (100GE DAC)\n* `100gbase-cwdm4` - 100GBASE-CWDM4 (100GE)\n* `100gbase-dr` - 100GBASE-DR (100GE)\n* `100gbase-er4` - 100GBASE-ER4 (100GE)\n* `100gbase-fr1` - 100GBASE-FR1 (100GE)\n* `100gbase-lr1` - 100GBASE-LR1 (100GE)\n* `100gbase-lr4` - 100GBASE-LR4 (100GE)\n* `100gbase-sr1` - 100GBASE-SR1 (100GE)\n* `100gbase-sr1.2` - 100GBASE-SR1.2 (100GE BiDi)\n* `100gbase-sr2` - 100GBASE-SR2 (100GE)\n* `100gbase-sr4` - 100GBASE-SR4 (100GE)\n* `100gbase-sr10` - 100GBASE-SR10 (100GE)\n* `100gbase-zr` - 100GBASE-ZR (100GE)\n* `200gbase-cr2` - 200GBASE-CR2 (200GE)\n* `200gbase-cr4` - 200GBASE-CR4 (200GE)\n* `200gbase-dr4` - 200GBASE-DR4 (200GE)\n* `200gbase-er4` - 200GBASE-ER4 (200GE)\n* `200gbase-fr4` - 200GBASE-FR4 (200GE)\n* `200gbase-lr4` - 200GBASE-LR4 (200GE)\n* `200gbase-sr2` - 200GBASE-SR2 (200GE)\n* `200gbase-sr4` - 200GBASE-SR4 (200GE)\n* `200gbase-vr2` - 200GBASE-VR2 (200GE)\n* `400gbase-cr4` - 400GBASE-CR4 (400GE)\n* `400gbase-dr4` - 400GBASE-DR4 (400GE)\n* `400gbase-er8` - 400GBASE-ER8 (400GE)\n* `400gbase-fr4` - 400GBASE-FR4 (400GE)\n* `400gbase-fr8` - 400GBASE-FR8 (400GE)\n* `400gbase-lr4` - 400GBASE-LR4 (400GE)\n* `400gbase-lr8` - 400GBASE-LR8 (400GE)\n* `400gbase-sr4` - 400GBASE-SR4 (400GE)\n* `400gbase-sr4_2` - 400GBASE-SR4.2 (400GE BiDi)\n* `400gbase-sr8` - 400GBASE-SR8 (400GE)\n* `400gbase-sr16` - 400GBASE-SR16 (400GE)\n* `400gbase-vr4` - 400GBASE-VR4 (400GE)\n* `400gbase-zr` - 400GBASE-ZR (400GE)\n* `800gbase-cr8` - 800GBASE-CR8 (800GE)\n* `800gbase-dr8` - 800GBASE-DR8 (800GE)\n* `800gbase-sr8` - 800GBASE-SR8 (800GE)\n* `800gbase-vr8` - 800GBASE-VR8 (800GE)\n* `1.6tbase-cr8` - 1.6TBASE-CR8 (1.6TE)\n* `1.6tbase-dr8` - 1.6TBASE-DR8 (1.6TE)\n* `1.6tbase-dr8-2` - 1.6TBASE-DR8-2 (1.6TE)\n* `100base-x-sfp` - SFP (100ME)\n* `1000base-x-gbic` - GBIC (1GE)\n* `1000base-x-sfp` - SFP (1GE)\n* `2.5gbase-x-sfp` - SFP (2.5GE)\n* `10gbase-x-sfpp` - SFP+ (10GE)\n* `10gbase-x-xenpak` - XENPAK (10GE)\n* `10gbase-x-xfp` - XFP (10GE)\n* `10gbase-x-x2` - X2 (10GE)\n* `25gbase-x-sfp28` - SFP28 (25GE)\n* `40gbase-x-qsfpp` - QSFP+ (40GE)\n* `50gbase-x-sfp28` - QSFP28 (50GE)\n* `50gbase-x-sfp56` - SFP56 (50GE)\n* `100gbase-x-cfp` - CFP (100GE)\n* `100gbase-x-cfp2` - CFP2 (100GE)\n* `100gbase-x-cfp4` - CFP4 (100GE)\n* `100gbase-x-cxp` - CXP (100GE)\n* `100gbase-x-cpak` - Cisco CPAK (100GE)\n* `100gbase-x-dsfp` - DSFP (100GE)\n* `100gbase-x-qsfp28` - QSFP28 (100GE)\n* `100gbase-x-qsfpdd` - QSFP-DD (100GE)\n* `100gbase-x-sfp112` - SFP112 (100GE)\n* `100gbase-x-sfpdd` - SFP-DD (100GE)\n* `200gbase-x-cfp2` - CFP2 (200GE)\n* `200gbase-x-qsfp56` - QSFP56 (200GE)\n* `200gbase-x-qsfpdd` - QSFP-DD (200GE)\n* `400gbase-x-qsfp112` - QSFP112 (400GE)\n* `400gbase-x-qsfpdd` - QSFP-DD (400GE)\n* `400gbase-x-cdfp` - CDFP (400GE)\n* `400gbase-x-cfp2` - CFP2 (400GE)\n* `400gbase-x-cfp8` - CPF8 (400GE)\n* `400gbase-x-osfp` - OSFP (400GE)\n* `400gbase-x-osfp-rhs` - OSFP-RHS (400GE)\n* `800gbase-x-osfp` - OSFP (800GE)\n* `800gbase-x-qsfpdd` - QSFP-DD (800GE)\n* `1.6tbase-x-osfp1600` - OSFP1600 (1.6TE)\n* `1.6tbase-x-osfp1600-rhs` - OSFP1600-RHS (1.6TE)\n* `1.6tbase-x-qsfpdd1600` - QSFP-DD1600 (1.6TE)\n* `1000base-kx` - 1000BASE-KX (1GE)\n* `2.5gbase-kx` - 2.5GBASE-KX (2.5GE)\n* `5gbase-kr` - 5GBASE-KR (5GE)\n* `10gbase-kr` - 10GBASE-KR (10GE)\n* `10gbase-kx4` - 10GBASE-KX4 (10GE)\n* `25gbase-kr` - 25GBASE-KR (25GE)\n* `40gbase-kr4` - 40GBASE-KR4 (40GE)\n* `50gbase-kr` - 50GBASE-KR (50GE)\n* `100gbase-kp4` - 100GBASE-KP4 (100GE)\n* `100gbase-kr2` - 100GBASE-KR2 (100GE)\n* `100gbase-kr4` - 100GBASE-KR4 (100GE)\n* `1.6tbase-kr8` - 1.6TBASE-KR8 (1.6TE)\n* `ieee802.11a` - IEEE 802.11a\n* `ieee802.11g` - IEEE 802.11b/g\n* `ieee802.11n` - IEEE 802.11n (Wi-Fi 4)\n* `ieee802.11ac` - IEEE 802.11ac (Wi-Fi 5)\n* `ieee802.11ad` - IEEE 802.11ad (WiGig)\n* `ieee802.11ax` - IEEE 802.11ax (Wi-Fi 6)\n* `ieee802.11ay` - IEEE 802.11ay (WiGig)\n* `ieee802.11be` - IEEE 802.11be (Wi-Fi 7)\n* `ieee802.15.1` - IEEE 802.15.1 (Bluetooth)\n* `ieee802.15.4` - IEEE 802.15.4 (LR-WPAN)\n* `other-wireless` - Other (Wireless)\n* `gsm` - GSM\n* `cdma` - CDMA\n* `lte` - LTE\n* `4g` - 4G\n* `5g` - 5G\n* `sonet-oc3` - OC-3/STM-1\n* `sonet-oc12` - OC-12/STM-4\n* `sonet-oc48` - OC-48/STM-16\n* `sonet-oc192` - OC-192/STM-64\n* `sonet-oc768` - OC-768/STM-256\n* `sonet-oc1920` - OC-1920/STM-640\n* `sonet-oc3840` - OC-3840/STM-1234\n* `1gfc-sfp` - SFP (1GFC)\n* `2gfc-sfp` - SFP (2GFC)\n* `4gfc-sfp` - SFP (4GFC)\n* `8gfc-sfpp` - SFP+ (8GFC)\n* `16gfc-sfpp` - SFP+ (16GFC)\n* `32gfc-sfp28` - SFP28 (32GFC)\n* `32gfc-sfpp` - SFP+ (32GFC)\n* `64gfc-qsfpp` - QSFP+ (64GFC)\n* `64gfc-sfpdd` - SFP-DD (64GFC)\n* `64gfc-sfpp` - SFP+ (64GFC)\n* `128gfc-qsfp28` - QSFP28 (128GFC)\n* `infiniband-sdr` - SDR (2 Gbps)\n* `infiniband-ddr` - DDR (4 Gbps)\n* `infiniband-qdr` - QDR (8 Gbps)\n* `infiniband-fdr10` - FDR10 (10 Gbps)\n* `infiniband-fdr` - FDR (13.5 Gbps)\n* `infiniband-edr` - EDR (25 Gbps)\n* `infiniband-hdr` - HDR (50 Gbps)\n* `infiniband-ndr` - NDR (100 Gbps)\n* `infiniband-xdr` - XDR (200 Gbps)\n* `infiniband-sdr-4x` - SDR 4X (8 Gbps)\n* `infiniband-ddr-4x` - DDR 4X (16 Gbps)\n* `infiniband-qdr-4x` - QDR 4X (32 Gbps)\n* `infiniband-fdr10-4x` - FDR10 4X (40 Gbps)\n* `infiniband-fdr-4x` - FDR 4X (56 Gbps)\n* `infiniband-edr-4x` - EDR 4X (100 Gbps)\n* `infiniband-hdr-4x` - HDR 4X (200 Gbps)\n* `infiniband-ndr-4x` - NDR 4X (400 Gbps)\n* `infiniband-xdr-4x` - XDR 4X (800 Gbps)\n* `t1` - T1 (1.544 Mbps)\n* `e1` - E1 (2.048 Mbps)\n* `t3` - T3 (45 Mbps)\n* `e3` - E3 (34 Mbps)\n* `xdsl` - xDSL\n* `docsis` - DOCSIS\n* `moca` - MoCA\n* `bpon` - BPON (622 Mbps / 155 Mbps)\n* `epon` - EPON (1 Gbps)\n* `10g-epon` - 10G-EPON (10 Gbps)\n* `gpon` - GPON (2.5 Gbps / 1.25 Gbps)\n* `xg-pon` - XG-PON (10 Gbps / 2.5 Gbps)\n* `xgs-pon` - XGS-PON (10 Gbps)\n* `ng-pon2` - NG-PON2 (TWDM-PON) (4x10 Gbps)\n* `25g-pon` - 25G-PON (25 Gbps)\n* `50g-pon` - 50G-PON (50 Gbps)\n* `cisco-stackwise` - Cisco StackWise\n* `cisco-stackwise-plus` - Cisco StackWise Plus\n* `cisco-flexstack` - Cisco FlexStack\n* `cisco-flexstack-plus` - Cisco FlexStack Plus\n* `cisco-stackwise-80` - Cisco StackWise-80\n* `cisco-stackwise-160` - Cisco StackWise-160\n* `cisco-stackwise-320` - Cisco StackWise-320\n* `cisco-stackwise-480` - Cisco StackWise-480\n* `cisco-stackwise-1t` - Cisco StackWise-1T\n* `juniper-vcp` - Juniper VCP\n* `extreme-summitstack` - Extreme SummitStack\n* `extreme-summitstack-128` - Extreme SummitStack-128\n* `extreme-summitstack-256` - Extreme SummitStack-256\n* `extreme-summitstack-512` - Extreme SummitStack-512\n* `hpe-synergy-interconnect-link` - HPE Synergy Interconnect Link\n* `other` - Other", - "x-spec-enum-id": "b0c97040e5abdff1" + "description": "* `virtual` - Virtual\n* `bridge` - Bridge\n* `lag` - Link Aggregation Group (LAG)\n* `channel` - Channel\n* `100base-fx` - 100BASE-FX (10/100ME)\n* `100base-lfx` - 100BASE-LFX (10/100ME)\n* `100base-tx` - 100BASE-TX (10/100ME)\n* `100base-t1` - 100BASE-T1 (10/100ME)\n* `1000base-bx10-d` - 1000BASE-BX10-D (1GE BiDi Down)\n* `1000base-bx10-u` - 1000BASE-BX10-U (1GE BiDi Up)\n* `1000base-cwdm` - 1000BASE-CWDM (1GE)\n* `1000base-cx` - 1000BASE-CX (1GE DAC)\n* `1000base-dwdm` - 1000BASE-DWDM (1GE)\n* `1000base-ex` - 1000BASE-EX (1GE)\n* `1000base-lsx` - 1000BASE-LSX (1GE)\n* `1000base-lx` - 1000BASE-LX (1GE)\n* `1000base-lx10` - 1000BASE-LX10/LH (1GE)\n* `1000base-sx` - 1000BASE-SX (1GE)\n* `1000base-t` - 1000BASE-T (1GE)\n* `1000base-tx` - 1000BASE-TX (1GE)\n* `1000base-zx` - 1000BASE-ZX (1GE)\n* `2.5gbase-t` - 2.5GBASE-T (2.5GE)\n* `5gbase-t` - 5GBASE-T (5GE)\n* `10gbase-br-d` - 10GBASE-BR-D (10GE BiDi Down)\n* `10gbase-br-u` - 10GBASE-BR-U (10GE BiDi Up)\n* `10gbase-cu` - 10GBASE-CU (10GE DAC Passive Twinax)\n* `10gbase-cx4` - 10GBASE-CX4 (10GE DAC)\n* `10gbase-er` - 10GBASE-ER (10GE)\n* `10gbase-lr` - 10GBASE-LR (10GE)\n* `10gbase-lrm` - 10GBASE-LRM (10GE)\n* `10gbase-lx4` - 10GBASE-LX4 (10GE)\n* `10gbase-sr` - 10GBASE-SR (10GE)\n* `10gbase-t` - 10GBASE-T (10GE)\n* `10gbase-zr` - 10GBASE-ZR (10GE)\n* `25gbase-cr` - 25GBASE-CR (25GE DAC)\n* `25gbase-er` - 25GBASE-ER (25GE)\n* `25gbase-lr` - 25GBASE-LR (25GE)\n* `25gbase-sr` - 25GBASE-SR (25GE)\n* `25gbase-t` - 25GBASE-T (25GE)\n* `40gbase-cr4` - 40GBASE-CR4 (40GE DAC)\n* `40gbase-er4` - 40GBASE-ER4 (40GE)\n* `40gbase-fr4` - 40GBASE-FR4 (40GE)\n* `40gbase-lr4` - 40GBASE-LR4 (40GE)\n* `40gbase-sr4` - 40GBASE-SR4 (40GE)\n* `40gbase-sr4-bd` - 40GBASE-SR4 (40GE BiDi)\n* `50gbase-cr` - 50GBASE-CR (50GE DAC)\n* `50gbase-er` - 50GBASE-ER (50GE)\n* `50gbase-fr` - 50GBASE-FR (50GE)\n* `50gbase-lr` - 50GBASE-LR (50GE)\n* `50gbase-sr` - 50GBASE-SR (50GE)\n* `100gbase-cr1` - 100GBASE-CR1 (100GE DAC)\n* `100gbase-cr2` - 100GBASE-CR2 (100GE DAC)\n* `100gbase-cr4` - 100GBASE-CR4 (100GE DAC)\n* `100gbase-cr10` - 100GBASE-CR10 (100GE DAC)\n* `100gbase-cwdm4` - 100GBASE-CWDM4 (100GE)\n* `100gbase-dr` - 100GBASE-DR (100GE)\n* `100gbase-er4` - 100GBASE-ER4 (100GE)\n* `100gbase-fr1` - 100GBASE-FR1 (100GE)\n* `100gbase-lr1` - 100GBASE-LR1 (100GE)\n* `100gbase-lr4` - 100GBASE-LR4 (100GE)\n* `100gbase-sr1` - 100GBASE-SR1 (100GE)\n* `100gbase-sr1.2` - 100GBASE-SR1.2 (100GE BiDi)\n* `100gbase-sr2` - 100GBASE-SR2 (100GE)\n* `100gbase-sr4` - 100GBASE-SR4 (100GE)\n* `100gbase-sr10` - 100GBASE-SR10 (100GE)\n* `100gbase-zr` - 100GBASE-ZR (100GE)\n* `200gbase-cr2` - 200GBASE-CR2 (200GE)\n* `200gbase-cr4` - 200GBASE-CR4 (200GE)\n* `200gbase-dr4` - 200GBASE-DR4 (200GE)\n* `200gbase-er4` - 200GBASE-ER4 (200GE)\n* `200gbase-fr4` - 200GBASE-FR4 (200GE)\n* `200gbase-lr4` - 200GBASE-LR4 (200GE)\n* `200gbase-sr2` - 200GBASE-SR2 (200GE)\n* `200gbase-sr4` - 200GBASE-SR4 (200GE)\n* `200gbase-vr2` - 200GBASE-VR2 (200GE)\n* `400gbase-cr4` - 400GBASE-CR4 (400GE)\n* `400gbase-dr4` - 400GBASE-DR4 (400GE)\n* `400gbase-er8` - 400GBASE-ER8 (400GE)\n* `400gbase-fr4` - 400GBASE-FR4 (400GE)\n* `400gbase-fr8` - 400GBASE-FR8 (400GE)\n* `400gbase-lr4` - 400GBASE-LR4 (400GE)\n* `400gbase-lr8` - 400GBASE-LR8 (400GE)\n* `400gbase-sr4` - 400GBASE-SR4 (400GE)\n* `400gbase-sr4_2` - 400GBASE-SR4.2 (400GE BiDi)\n* `400gbase-sr8` - 400GBASE-SR8 (400GE)\n* `400gbase-sr16` - 400GBASE-SR16 (400GE)\n* `400gbase-vr4` - 400GBASE-VR4 (400GE)\n* `400gbase-zr` - 400GBASE-ZR (400GE)\n* `800gbase-cr8` - 800GBASE-CR8 (800GE)\n* `800gbase-dr8` - 800GBASE-DR8 (800GE)\n* `800gbase-sr8` - 800GBASE-SR8 (800GE)\n* `800gbase-vr8` - 800GBASE-VR8 (800GE)\n* `1.6tbase-cr8` - 1.6TBASE-CR8 (1.6TE)\n* `1.6tbase-dr8` - 1.6TBASE-DR8 (1.6TE)\n* `1.6tbase-dr8-2` - 1.6TBASE-DR8-2 (1.6TE)\n* `100base-x-sfp` - SFP (100ME)\n* `1000base-x-gbic` - GBIC (1GE)\n* `1000base-x-sfp` - SFP (1GE)\n* `2.5gbase-x-sfp` - SFP (2.5GE)\n* `10gbase-x-sfpp` - SFP+ (10GE)\n* `10gbase-x-xenpak` - XENPAK (10GE)\n* `10gbase-x-xfp` - XFP (10GE)\n* `10gbase-x-x2` - X2 (10GE)\n* `25gbase-x-sfp28` - SFP28 (25GE)\n* `40gbase-x-qsfpp` - QSFP+ (40GE)\n* `50gbase-x-sfp28` - QSFP28 (50GE)\n* `50gbase-x-sfp56` - SFP56 (50GE)\n* `100gbase-x-cfp` - CFP (100GE)\n* `100gbase-x-cfp2` - CFP2 (100GE)\n* `100gbase-x-cfp4` - CFP4 (100GE)\n* `100gbase-x-cxp` - CXP (100GE)\n* `100gbase-x-cpak` - Cisco CPAK (100GE)\n* `100gbase-x-dsfp` - DSFP (100GE)\n* `100gbase-x-qsfp28` - QSFP28 (100GE)\n* `100gbase-x-qsfpdd` - QSFP-DD (100GE)\n* `100gbase-x-sfp112` - SFP112 (100GE)\n* `100gbase-x-sfpdd` - SFP-DD (100GE)\n* `200gbase-x-cfp2` - CFP2 (200GE)\n* `200gbase-x-qsfp56` - QSFP56 (200GE)\n* `200gbase-x-qsfpdd` - QSFP-DD (200GE)\n* `400gbase-x-qsfp112` - QSFP112 (400GE)\n* `400gbase-x-qsfpdd` - QSFP-DD (400GE)\n* `400gbase-x-cdfp` - CDFP (400GE)\n* `400gbase-x-cfp2` - CFP2 (400GE)\n* `400gbase-x-cfp8` - CPF8 (400GE)\n* `400gbase-x-osfp` - OSFP (400GE)\n* `400gbase-x-osfp-rhs` - OSFP-RHS (400GE)\n* `800gbase-x-osfp` - OSFP (800GE)\n* `800gbase-x-qsfpdd` - QSFP-DD (800GE)\n* `1.6tbase-x-osfp1600` - OSFP1600 (1.6TE)\n* `1.6tbase-x-osfp1600-rhs` - OSFP1600-RHS (1.6TE)\n* `1.6tbase-x-qsfpdd1600` - QSFP-DD1600 (1.6TE)\n* `1000base-kx` - 1000BASE-KX (1GE)\n* `2.5gbase-kx` - 2.5GBASE-KX (2.5GE)\n* `5gbase-kr` - 5GBASE-KR (5GE)\n* `10gbase-kr` - 10GBASE-KR (10GE)\n* `10gbase-kx4` - 10GBASE-KX4 (10GE)\n* `25gbase-kr` - 25GBASE-KR (25GE)\n* `40gbase-kr4` - 40GBASE-KR4 (40GE)\n* `50gbase-kr` - 50GBASE-KR (50GE)\n* `100gbase-kp4` - 100GBASE-KP4 (100GE)\n* `100gbase-kr2` - 100GBASE-KR2 (100GE)\n* `100gbase-kr4` - 100GBASE-KR4 (100GE)\n* `1.6tbase-kr8` - 1.6TBASE-KR8 (1.6TE)\n* `ieee802.11a` - IEEE 802.11a\n* `ieee802.11g` - IEEE 802.11b/g\n* `ieee802.11n` - IEEE 802.11n (Wi-Fi 4)\n* `ieee802.11ac` - IEEE 802.11ac (Wi-Fi 5)\n* `ieee802.11ad` - IEEE 802.11ad (WiGig)\n* `ieee802.11ax` - IEEE 802.11ax (Wi-Fi 6)\n* `ieee802.11ay` - IEEE 802.11ay (WiGig)\n* `ieee802.11be` - IEEE 802.11be (Wi-Fi 7)\n* `ieee802.15.1` - IEEE 802.15.1 (Bluetooth)\n* `ieee802.15.4` - IEEE 802.15.4 (LR-WPAN)\n* `other-wireless` - Other (Wireless)\n* `gsm` - GSM\n* `cdma` - CDMA\n* `lte` - LTE\n* `4g` - 4G\n* `5g` - 5G\n* `sonet-oc3` - OC-3/STM-1\n* `sonet-oc12` - OC-12/STM-4\n* `sonet-oc48` - OC-48/STM-16\n* `sonet-oc192` - OC-192/STM-64\n* `sonet-oc768` - OC-768/STM-256\n* `sonet-oc1920` - OC-1920/STM-640\n* `sonet-oc3840` - OC-3840/STM-1234\n* `1gfc-sfp` - SFP (1GFC)\n* `2gfc-sfp` - SFP (2GFC)\n* `4gfc-sfp` - SFP (4GFC)\n* `8gfc-sfpp` - SFP+ (8GFC)\n* `16gfc-sfpp` - SFP+ (16GFC)\n* `32gfc-sfp28` - SFP28 (32GFC)\n* `32gfc-sfpp` - SFP+ (32GFC)\n* `64gfc-qsfpp` - QSFP+ (64GFC)\n* `64gfc-sfpdd` - SFP-DD (64GFC)\n* `64gfc-sfpp` - SFP+ (64GFC)\n* `128gfc-qsfp28` - QSFP28 (128GFC)\n* `infiniband-sdr` - SDR (2 Gbps)\n* `infiniband-ddr` - DDR (4 Gbps)\n* `infiniband-qdr` - QDR (8 Gbps)\n* `infiniband-fdr10` - FDR10 (10 Gbps)\n* `infiniband-fdr` - FDR (13.5 Gbps)\n* `infiniband-edr` - EDR (25 Gbps)\n* `infiniband-hdr` - HDR (50 Gbps)\n* `infiniband-ndr` - NDR (100 Gbps)\n* `infiniband-xdr` - XDR (200 Gbps)\n* `infiniband-sdr-4x` - SDR 4X (8 Gbps)\n* `infiniband-ddr-4x` - DDR 4X (16 Gbps)\n* `infiniband-qdr-4x` - QDR 4X (32 Gbps)\n* `infiniband-fdr10-4x` - FDR10 4X (40 Gbps)\n* `infiniband-fdr-4x` - FDR 4X (56 Gbps)\n* `infiniband-edr-4x` - EDR 4X (100 Gbps)\n* `infiniband-hdr-4x` - HDR 4X (200 Gbps)\n* `infiniband-ndr-4x` - NDR 4X (400 Gbps)\n* `infiniband-xdr-4x` - XDR 4X (800 Gbps)\n* `t1` - T1 (1.544 Mbps)\n* `e1` - E1 (2.048 Mbps)\n* `t3` - T3 (45 Mbps)\n* `e3` - E3 (34 Mbps)\n* `xdsl` - xDSL\n* `docsis` - DOCSIS\n* `moca` - MoCA\n* `bpon` - BPON (622 Mbps / 155 Mbps)\n* `epon` - EPON (1 Gbps)\n* `10g-epon` - 10G-EPON (10 Gbps)\n* `gpon` - GPON (2.5 Gbps / 1.25 Gbps)\n* `xg-pon` - XG-PON (10 Gbps / 2.5 Gbps)\n* `xgs-pon` - XGS-PON (10 Gbps)\n* `ng-pon2` - NG-PON2 (TWDM-PON) (4x10 Gbps)\n* `25g-pon` - 25G-PON (25 Gbps)\n* `50g-pon` - 50G-PON (50 Gbps)\n* `cisco-stackwise` - Cisco StackWise\n* `cisco-stackwise-plus` - Cisco StackWise Plus\n* `cisco-flexstack` - Cisco FlexStack\n* `cisco-flexstack-plus` - Cisco FlexStack Plus\n* `cisco-stackwise-80` - Cisco StackWise-80\n* `cisco-stackwise-160` - Cisco StackWise-160\n* `cisco-stackwise-320` - Cisco StackWise-320\n* `cisco-stackwise-480` - Cisco StackWise-480\n* `cisco-stackwise-1t` - Cisco StackWise-1T\n* `juniper-vcp` - Juniper VCP\n* `extreme-summitstack` - Extreme SummitStack\n* `extreme-summitstack-128` - Extreme SummitStack-128\n* `extreme-summitstack-256` - Extreme SummitStack-256\n* `extreme-summitstack-512` - Extreme SummitStack-512\n* `hpe-synergy-interconnect-link` - HPE Synergy Interconnect Link\n* `other` - Other", + "x-spec-enum-id": "19cc901fcea417ad" }, "label": { "type": "string", @@ -272757,6 +306081,7 @@ "Virtual", "Bridge", "Link Aggregation Group (LAG)", + "Channel", "100BASE-FX (10/100ME)", "100BASE-LFX (10/100ME)", "100BASE-TX (10/100ME)", @@ -272985,6 +306310,20 @@ } } }, + "channels": { + "type": "integer", + "maximum": 1024, + "minimum": 1, + "nullable": true, + "description": "The number of channels into which this interface is channelized" + }, + "channel_id": { + "type": "integer", + "maximum": 1024, + "minimum": 1, + "nullable": true, + "description": "The channel on the parent interface to which this subinterface is bound" + }, "enabled": { "type": "boolean" }, @@ -272996,6 +306335,14 @@ "type": "string", "maxLength": 200 }, + "parent": { + "allOf": [ + { + "$ref": "#/components/schemas/NestedInterfaceTemplate" + } + ], + "nullable": true + }, "bridge": { "allOf": [ { @@ -273163,6 +306510,7 @@ "virtual", "bridge", "lag", + "channel", "100base-fx", "100base-lfx", "100base-tx", @@ -273389,8 +306737,22 @@ "other" ], "type": "string", - "description": "* `virtual` - Virtual\n* `bridge` - Bridge\n* `lag` - Link Aggregation Group (LAG)\n* `100base-fx` - 100BASE-FX (10/100ME)\n* `100base-lfx` - 100BASE-LFX (10/100ME)\n* `100base-tx` - 100BASE-TX (10/100ME)\n* `100base-t1` - 100BASE-T1 (10/100ME)\n* `1000base-bx10-d` - 1000BASE-BX10-D (1GE BiDi Down)\n* `1000base-bx10-u` - 1000BASE-BX10-U (1GE BiDi Up)\n* `1000base-cwdm` - 1000BASE-CWDM (1GE)\n* `1000base-cx` - 1000BASE-CX (1GE DAC)\n* `1000base-dwdm` - 1000BASE-DWDM (1GE)\n* `1000base-ex` - 1000BASE-EX (1GE)\n* `1000base-lsx` - 1000BASE-LSX (1GE)\n* `1000base-lx` - 1000BASE-LX (1GE)\n* `1000base-lx10` - 1000BASE-LX10/LH (1GE)\n* `1000base-sx` - 1000BASE-SX (1GE)\n* `1000base-t` - 1000BASE-T (1GE)\n* `1000base-tx` - 1000BASE-TX (1GE)\n* `1000base-zx` - 1000BASE-ZX (1GE)\n* `2.5gbase-t` - 2.5GBASE-T (2.5GE)\n* `5gbase-t` - 5GBASE-T (5GE)\n* `10gbase-br-d` - 10GBASE-BR-D (10GE BiDi Down)\n* `10gbase-br-u` - 10GBASE-BR-U (10GE BiDi Up)\n* `10gbase-cu` - 10GBASE-CU (10GE DAC Passive Twinax)\n* `10gbase-cx4` - 10GBASE-CX4 (10GE DAC)\n* `10gbase-er` - 10GBASE-ER (10GE)\n* `10gbase-lr` - 10GBASE-LR (10GE)\n* `10gbase-lrm` - 10GBASE-LRM (10GE)\n* `10gbase-lx4` - 10GBASE-LX4 (10GE)\n* `10gbase-sr` - 10GBASE-SR (10GE)\n* `10gbase-t` - 10GBASE-T (10GE)\n* `10gbase-zr` - 10GBASE-ZR (10GE)\n* `25gbase-cr` - 25GBASE-CR (25GE DAC)\n* `25gbase-er` - 25GBASE-ER (25GE)\n* `25gbase-lr` - 25GBASE-LR (25GE)\n* `25gbase-sr` - 25GBASE-SR (25GE)\n* `25gbase-t` - 25GBASE-T (25GE)\n* `40gbase-cr4` - 40GBASE-CR4 (40GE DAC)\n* `40gbase-er4` - 40GBASE-ER4 (40GE)\n* `40gbase-fr4` - 40GBASE-FR4 (40GE)\n* `40gbase-lr4` - 40GBASE-LR4 (40GE)\n* `40gbase-sr4` - 40GBASE-SR4 (40GE)\n* `40gbase-sr4-bd` - 40GBASE-SR4 (40GE BiDi)\n* `50gbase-cr` - 50GBASE-CR (50GE DAC)\n* `50gbase-er` - 50GBASE-ER (50GE)\n* `50gbase-fr` - 50GBASE-FR (50GE)\n* `50gbase-lr` - 50GBASE-LR (50GE)\n* `50gbase-sr` - 50GBASE-SR (50GE)\n* `100gbase-cr1` - 100GBASE-CR1 (100GE DAC)\n* `100gbase-cr2` - 100GBASE-CR2 (100GE DAC)\n* `100gbase-cr4` - 100GBASE-CR4 (100GE DAC)\n* `100gbase-cr10` - 100GBASE-CR10 (100GE DAC)\n* `100gbase-cwdm4` - 100GBASE-CWDM4 (100GE)\n* `100gbase-dr` - 100GBASE-DR (100GE)\n* `100gbase-er4` - 100GBASE-ER4 (100GE)\n* `100gbase-fr1` - 100GBASE-FR1 (100GE)\n* `100gbase-lr1` - 100GBASE-LR1 (100GE)\n* `100gbase-lr4` - 100GBASE-LR4 (100GE)\n* `100gbase-sr1` - 100GBASE-SR1 (100GE)\n* `100gbase-sr1.2` - 100GBASE-SR1.2 (100GE BiDi)\n* `100gbase-sr2` - 100GBASE-SR2 (100GE)\n* `100gbase-sr4` - 100GBASE-SR4 (100GE)\n* `100gbase-sr10` - 100GBASE-SR10 (100GE)\n* `100gbase-zr` - 100GBASE-ZR (100GE)\n* `200gbase-cr2` - 200GBASE-CR2 (200GE)\n* `200gbase-cr4` - 200GBASE-CR4 (200GE)\n* `200gbase-dr4` - 200GBASE-DR4 (200GE)\n* `200gbase-er4` - 200GBASE-ER4 (200GE)\n* `200gbase-fr4` - 200GBASE-FR4 (200GE)\n* `200gbase-lr4` - 200GBASE-LR4 (200GE)\n* `200gbase-sr2` - 200GBASE-SR2 (200GE)\n* `200gbase-sr4` - 200GBASE-SR4 (200GE)\n* `200gbase-vr2` - 200GBASE-VR2 (200GE)\n* `400gbase-cr4` - 400GBASE-CR4 (400GE)\n* `400gbase-dr4` - 400GBASE-DR4 (400GE)\n* `400gbase-er8` - 400GBASE-ER8 (400GE)\n* `400gbase-fr4` - 400GBASE-FR4 (400GE)\n* `400gbase-fr8` - 400GBASE-FR8 (400GE)\n* `400gbase-lr4` - 400GBASE-LR4 (400GE)\n* `400gbase-lr8` - 400GBASE-LR8 (400GE)\n* `400gbase-sr4` - 400GBASE-SR4 (400GE)\n* `400gbase-sr4_2` - 400GBASE-SR4.2 (400GE BiDi)\n* `400gbase-sr8` - 400GBASE-SR8 (400GE)\n* `400gbase-sr16` - 400GBASE-SR16 (400GE)\n* `400gbase-vr4` - 400GBASE-VR4 (400GE)\n* `400gbase-zr` - 400GBASE-ZR (400GE)\n* `800gbase-cr8` - 800GBASE-CR8 (800GE)\n* `800gbase-dr8` - 800GBASE-DR8 (800GE)\n* `800gbase-sr8` - 800GBASE-SR8 (800GE)\n* `800gbase-vr8` - 800GBASE-VR8 (800GE)\n* `1.6tbase-cr8` - 1.6TBASE-CR8 (1.6TE)\n* `1.6tbase-dr8` - 1.6TBASE-DR8 (1.6TE)\n* `1.6tbase-dr8-2` - 1.6TBASE-DR8-2 (1.6TE)\n* `100base-x-sfp` - SFP (100ME)\n* `1000base-x-gbic` - GBIC (1GE)\n* `1000base-x-sfp` - SFP (1GE)\n* `2.5gbase-x-sfp` - SFP (2.5GE)\n* `10gbase-x-sfpp` - SFP+ (10GE)\n* `10gbase-x-xenpak` - XENPAK (10GE)\n* `10gbase-x-xfp` - XFP (10GE)\n* `10gbase-x-x2` - X2 (10GE)\n* `25gbase-x-sfp28` - SFP28 (25GE)\n* `40gbase-x-qsfpp` - QSFP+ (40GE)\n* `50gbase-x-sfp28` - QSFP28 (50GE)\n* `50gbase-x-sfp56` - SFP56 (50GE)\n* `100gbase-x-cfp` - CFP (100GE)\n* `100gbase-x-cfp2` - CFP2 (100GE)\n* `100gbase-x-cfp4` - CFP4 (100GE)\n* `100gbase-x-cxp` - CXP (100GE)\n* `100gbase-x-cpak` - Cisco CPAK (100GE)\n* `100gbase-x-dsfp` - DSFP (100GE)\n* `100gbase-x-qsfp28` - QSFP28 (100GE)\n* `100gbase-x-qsfpdd` - QSFP-DD (100GE)\n* `100gbase-x-sfp112` - SFP112 (100GE)\n* `100gbase-x-sfpdd` - SFP-DD (100GE)\n* `200gbase-x-cfp2` - CFP2 (200GE)\n* `200gbase-x-qsfp56` - QSFP56 (200GE)\n* `200gbase-x-qsfpdd` - QSFP-DD (200GE)\n* `400gbase-x-qsfp112` - QSFP112 (400GE)\n* `400gbase-x-qsfpdd` - QSFP-DD (400GE)\n* `400gbase-x-cdfp` - CDFP (400GE)\n* `400gbase-x-cfp2` - CFP2 (400GE)\n* `400gbase-x-cfp8` - CPF8 (400GE)\n* `400gbase-x-osfp` - OSFP (400GE)\n* `400gbase-x-osfp-rhs` - OSFP-RHS (400GE)\n* `800gbase-x-osfp` - OSFP (800GE)\n* `800gbase-x-qsfpdd` - QSFP-DD (800GE)\n* `1.6tbase-x-osfp1600` - OSFP1600 (1.6TE)\n* `1.6tbase-x-osfp1600-rhs` - OSFP1600-RHS (1.6TE)\n* `1.6tbase-x-qsfpdd1600` - QSFP-DD1600 (1.6TE)\n* `1000base-kx` - 1000BASE-KX (1GE)\n* `2.5gbase-kx` - 2.5GBASE-KX (2.5GE)\n* `5gbase-kr` - 5GBASE-KR (5GE)\n* `10gbase-kr` - 10GBASE-KR (10GE)\n* `10gbase-kx4` - 10GBASE-KX4 (10GE)\n* `25gbase-kr` - 25GBASE-KR (25GE)\n* `40gbase-kr4` - 40GBASE-KR4 (40GE)\n* `50gbase-kr` - 50GBASE-KR (50GE)\n* `100gbase-kp4` - 100GBASE-KP4 (100GE)\n* `100gbase-kr2` - 100GBASE-KR2 (100GE)\n* `100gbase-kr4` - 100GBASE-KR4 (100GE)\n* `1.6tbase-kr8` - 1.6TBASE-KR8 (1.6TE)\n* `ieee802.11a` - IEEE 802.11a\n* `ieee802.11g` - IEEE 802.11b/g\n* `ieee802.11n` - IEEE 802.11n (Wi-Fi 4)\n* `ieee802.11ac` - IEEE 802.11ac (Wi-Fi 5)\n* `ieee802.11ad` - IEEE 802.11ad (WiGig)\n* `ieee802.11ax` - IEEE 802.11ax (Wi-Fi 6)\n* `ieee802.11ay` - IEEE 802.11ay (WiGig)\n* `ieee802.11be` - IEEE 802.11be (Wi-Fi 7)\n* `ieee802.15.1` - IEEE 802.15.1 (Bluetooth)\n* `ieee802.15.4` - IEEE 802.15.4 (LR-WPAN)\n* `other-wireless` - Other (Wireless)\n* `gsm` - GSM\n* `cdma` - CDMA\n* `lte` - LTE\n* `4g` - 4G\n* `5g` - 5G\n* `sonet-oc3` - OC-3/STM-1\n* `sonet-oc12` - OC-12/STM-4\n* `sonet-oc48` - OC-48/STM-16\n* `sonet-oc192` - OC-192/STM-64\n* `sonet-oc768` - OC-768/STM-256\n* `sonet-oc1920` - OC-1920/STM-640\n* `sonet-oc3840` - OC-3840/STM-1234\n* `1gfc-sfp` - SFP (1GFC)\n* `2gfc-sfp` - SFP (2GFC)\n* `4gfc-sfp` - SFP (4GFC)\n* `8gfc-sfpp` - SFP+ (8GFC)\n* `16gfc-sfpp` - SFP+ (16GFC)\n* `32gfc-sfp28` - SFP28 (32GFC)\n* `32gfc-sfpp` - SFP+ (32GFC)\n* `64gfc-qsfpp` - QSFP+ (64GFC)\n* `64gfc-sfpdd` - SFP-DD (64GFC)\n* `64gfc-sfpp` - SFP+ (64GFC)\n* `128gfc-qsfp28` - QSFP28 (128GFC)\n* `infiniband-sdr` - SDR (2 Gbps)\n* `infiniband-ddr` - DDR (4 Gbps)\n* `infiniband-qdr` - QDR (8 Gbps)\n* `infiniband-fdr10` - FDR10 (10 Gbps)\n* `infiniband-fdr` - FDR (13.5 Gbps)\n* `infiniband-edr` - EDR (25 Gbps)\n* `infiniband-hdr` - HDR (50 Gbps)\n* `infiniband-ndr` - NDR (100 Gbps)\n* `infiniband-xdr` - XDR (200 Gbps)\n* `infiniband-sdr-4x` - SDR 4X (8 Gbps)\n* `infiniband-ddr-4x` - DDR 4X (16 Gbps)\n* `infiniband-qdr-4x` - QDR 4X (32 Gbps)\n* `infiniband-fdr10-4x` - FDR10 4X (40 Gbps)\n* `infiniband-fdr-4x` - FDR 4X (56 Gbps)\n* `infiniband-edr-4x` - EDR 4X (100 Gbps)\n* `infiniband-hdr-4x` - HDR 4X (200 Gbps)\n* `infiniband-ndr-4x` - NDR 4X (400 Gbps)\n* `infiniband-xdr-4x` - XDR 4X (800 Gbps)\n* `t1` - T1 (1.544 Mbps)\n* `e1` - E1 (2.048 Mbps)\n* `t3` - T3 (45 Mbps)\n* `e3` - E3 (34 Mbps)\n* `xdsl` - xDSL\n* `docsis` - DOCSIS\n* `moca` - MoCA\n* `bpon` - BPON (622 Mbps / 155 Mbps)\n* `epon` - EPON (1 Gbps)\n* `10g-epon` - 10G-EPON (10 Gbps)\n* `gpon` - GPON (2.5 Gbps / 1.25 Gbps)\n* `xg-pon` - XG-PON (10 Gbps / 2.5 Gbps)\n* `xgs-pon` - XGS-PON (10 Gbps)\n* `ng-pon2` - NG-PON2 (TWDM-PON) (4x10 Gbps)\n* `25g-pon` - 25G-PON (25 Gbps)\n* `50g-pon` - 50G-PON (50 Gbps)\n* `cisco-stackwise` - Cisco StackWise\n* `cisco-stackwise-plus` - Cisco StackWise Plus\n* `cisco-flexstack` - Cisco FlexStack\n* `cisco-flexstack-plus` - Cisco FlexStack Plus\n* `cisco-stackwise-80` - Cisco StackWise-80\n* `cisco-stackwise-160` - Cisco StackWise-160\n* `cisco-stackwise-320` - Cisco StackWise-320\n* `cisco-stackwise-480` - Cisco StackWise-480\n* `cisco-stackwise-1t` - Cisco StackWise-1T\n* `juniper-vcp` - Juniper VCP\n* `extreme-summitstack` - Extreme SummitStack\n* `extreme-summitstack-128` - Extreme SummitStack-128\n* `extreme-summitstack-256` - Extreme SummitStack-256\n* `extreme-summitstack-512` - Extreme SummitStack-512\n* `hpe-synergy-interconnect-link` - HPE Synergy Interconnect Link\n* `other` - Other", - "x-spec-enum-id": "b0c97040e5abdff1" + "description": "* `virtual` - Virtual\n* `bridge` - Bridge\n* `lag` - Link Aggregation Group (LAG)\n* `channel` - Channel\n* `100base-fx` - 100BASE-FX (10/100ME)\n* `100base-lfx` - 100BASE-LFX (10/100ME)\n* `100base-tx` - 100BASE-TX (10/100ME)\n* `100base-t1` - 100BASE-T1 (10/100ME)\n* `1000base-bx10-d` - 1000BASE-BX10-D (1GE BiDi Down)\n* `1000base-bx10-u` - 1000BASE-BX10-U (1GE BiDi Up)\n* `1000base-cwdm` - 1000BASE-CWDM (1GE)\n* `1000base-cx` - 1000BASE-CX (1GE DAC)\n* `1000base-dwdm` - 1000BASE-DWDM (1GE)\n* `1000base-ex` - 1000BASE-EX (1GE)\n* `1000base-lsx` - 1000BASE-LSX (1GE)\n* `1000base-lx` - 1000BASE-LX (1GE)\n* `1000base-lx10` - 1000BASE-LX10/LH (1GE)\n* `1000base-sx` - 1000BASE-SX (1GE)\n* `1000base-t` - 1000BASE-T (1GE)\n* `1000base-tx` - 1000BASE-TX (1GE)\n* `1000base-zx` - 1000BASE-ZX (1GE)\n* `2.5gbase-t` - 2.5GBASE-T (2.5GE)\n* `5gbase-t` - 5GBASE-T (5GE)\n* `10gbase-br-d` - 10GBASE-BR-D (10GE BiDi Down)\n* `10gbase-br-u` - 10GBASE-BR-U (10GE BiDi Up)\n* `10gbase-cu` - 10GBASE-CU (10GE DAC Passive Twinax)\n* `10gbase-cx4` - 10GBASE-CX4 (10GE DAC)\n* `10gbase-er` - 10GBASE-ER (10GE)\n* `10gbase-lr` - 10GBASE-LR (10GE)\n* `10gbase-lrm` - 10GBASE-LRM (10GE)\n* `10gbase-lx4` - 10GBASE-LX4 (10GE)\n* `10gbase-sr` - 10GBASE-SR (10GE)\n* `10gbase-t` - 10GBASE-T (10GE)\n* `10gbase-zr` - 10GBASE-ZR (10GE)\n* `25gbase-cr` - 25GBASE-CR (25GE DAC)\n* `25gbase-er` - 25GBASE-ER (25GE)\n* `25gbase-lr` - 25GBASE-LR (25GE)\n* `25gbase-sr` - 25GBASE-SR (25GE)\n* `25gbase-t` - 25GBASE-T (25GE)\n* `40gbase-cr4` - 40GBASE-CR4 (40GE DAC)\n* `40gbase-er4` - 40GBASE-ER4 (40GE)\n* `40gbase-fr4` - 40GBASE-FR4 (40GE)\n* `40gbase-lr4` - 40GBASE-LR4 (40GE)\n* `40gbase-sr4` - 40GBASE-SR4 (40GE)\n* `40gbase-sr4-bd` - 40GBASE-SR4 (40GE BiDi)\n* `50gbase-cr` - 50GBASE-CR (50GE DAC)\n* `50gbase-er` - 50GBASE-ER (50GE)\n* `50gbase-fr` - 50GBASE-FR (50GE)\n* `50gbase-lr` - 50GBASE-LR (50GE)\n* `50gbase-sr` - 50GBASE-SR (50GE)\n* `100gbase-cr1` - 100GBASE-CR1 (100GE DAC)\n* `100gbase-cr2` - 100GBASE-CR2 (100GE DAC)\n* `100gbase-cr4` - 100GBASE-CR4 (100GE DAC)\n* `100gbase-cr10` - 100GBASE-CR10 (100GE DAC)\n* `100gbase-cwdm4` - 100GBASE-CWDM4 (100GE)\n* `100gbase-dr` - 100GBASE-DR (100GE)\n* `100gbase-er4` - 100GBASE-ER4 (100GE)\n* `100gbase-fr1` - 100GBASE-FR1 (100GE)\n* `100gbase-lr1` - 100GBASE-LR1 (100GE)\n* `100gbase-lr4` - 100GBASE-LR4 (100GE)\n* `100gbase-sr1` - 100GBASE-SR1 (100GE)\n* `100gbase-sr1.2` - 100GBASE-SR1.2 (100GE BiDi)\n* `100gbase-sr2` - 100GBASE-SR2 (100GE)\n* `100gbase-sr4` - 100GBASE-SR4 (100GE)\n* `100gbase-sr10` - 100GBASE-SR10 (100GE)\n* `100gbase-zr` - 100GBASE-ZR (100GE)\n* `200gbase-cr2` - 200GBASE-CR2 (200GE)\n* `200gbase-cr4` - 200GBASE-CR4 (200GE)\n* `200gbase-dr4` - 200GBASE-DR4 (200GE)\n* `200gbase-er4` - 200GBASE-ER4 (200GE)\n* `200gbase-fr4` - 200GBASE-FR4 (200GE)\n* `200gbase-lr4` - 200GBASE-LR4 (200GE)\n* `200gbase-sr2` - 200GBASE-SR2 (200GE)\n* `200gbase-sr4` - 200GBASE-SR4 (200GE)\n* `200gbase-vr2` - 200GBASE-VR2 (200GE)\n* `400gbase-cr4` - 400GBASE-CR4 (400GE)\n* `400gbase-dr4` - 400GBASE-DR4 (400GE)\n* `400gbase-er8` - 400GBASE-ER8 (400GE)\n* `400gbase-fr4` - 400GBASE-FR4 (400GE)\n* `400gbase-fr8` - 400GBASE-FR8 (400GE)\n* `400gbase-lr4` - 400GBASE-LR4 (400GE)\n* `400gbase-lr8` - 400GBASE-LR8 (400GE)\n* `400gbase-sr4` - 400GBASE-SR4 (400GE)\n* `400gbase-sr4_2` - 400GBASE-SR4.2 (400GE BiDi)\n* `400gbase-sr8` - 400GBASE-SR8 (400GE)\n* `400gbase-sr16` - 400GBASE-SR16 (400GE)\n* `400gbase-vr4` - 400GBASE-VR4 (400GE)\n* `400gbase-zr` - 400GBASE-ZR (400GE)\n* `800gbase-cr8` - 800GBASE-CR8 (800GE)\n* `800gbase-dr8` - 800GBASE-DR8 (800GE)\n* `800gbase-sr8` - 800GBASE-SR8 (800GE)\n* `800gbase-vr8` - 800GBASE-VR8 (800GE)\n* `1.6tbase-cr8` - 1.6TBASE-CR8 (1.6TE)\n* `1.6tbase-dr8` - 1.6TBASE-DR8 (1.6TE)\n* `1.6tbase-dr8-2` - 1.6TBASE-DR8-2 (1.6TE)\n* `100base-x-sfp` - SFP (100ME)\n* `1000base-x-gbic` - GBIC (1GE)\n* `1000base-x-sfp` - SFP (1GE)\n* `2.5gbase-x-sfp` - SFP (2.5GE)\n* `10gbase-x-sfpp` - SFP+ (10GE)\n* `10gbase-x-xenpak` - XENPAK (10GE)\n* `10gbase-x-xfp` - XFP (10GE)\n* `10gbase-x-x2` - X2 (10GE)\n* `25gbase-x-sfp28` - SFP28 (25GE)\n* `40gbase-x-qsfpp` - QSFP+ (40GE)\n* `50gbase-x-sfp28` - QSFP28 (50GE)\n* `50gbase-x-sfp56` - SFP56 (50GE)\n* `100gbase-x-cfp` - CFP (100GE)\n* `100gbase-x-cfp2` - CFP2 (100GE)\n* `100gbase-x-cfp4` - CFP4 (100GE)\n* `100gbase-x-cxp` - CXP (100GE)\n* `100gbase-x-cpak` - Cisco CPAK (100GE)\n* `100gbase-x-dsfp` - DSFP (100GE)\n* `100gbase-x-qsfp28` - QSFP28 (100GE)\n* `100gbase-x-qsfpdd` - QSFP-DD (100GE)\n* `100gbase-x-sfp112` - SFP112 (100GE)\n* `100gbase-x-sfpdd` - SFP-DD (100GE)\n* `200gbase-x-cfp2` - CFP2 (200GE)\n* `200gbase-x-qsfp56` - QSFP56 (200GE)\n* `200gbase-x-qsfpdd` - QSFP-DD (200GE)\n* `400gbase-x-qsfp112` - QSFP112 (400GE)\n* `400gbase-x-qsfpdd` - QSFP-DD (400GE)\n* `400gbase-x-cdfp` - CDFP (400GE)\n* `400gbase-x-cfp2` - CFP2 (400GE)\n* `400gbase-x-cfp8` - CPF8 (400GE)\n* `400gbase-x-osfp` - OSFP (400GE)\n* `400gbase-x-osfp-rhs` - OSFP-RHS (400GE)\n* `800gbase-x-osfp` - OSFP (800GE)\n* `800gbase-x-qsfpdd` - QSFP-DD (800GE)\n* `1.6tbase-x-osfp1600` - OSFP1600 (1.6TE)\n* `1.6tbase-x-osfp1600-rhs` - OSFP1600-RHS (1.6TE)\n* `1.6tbase-x-qsfpdd1600` - QSFP-DD1600 (1.6TE)\n* `1000base-kx` - 1000BASE-KX (1GE)\n* `2.5gbase-kx` - 2.5GBASE-KX (2.5GE)\n* `5gbase-kr` - 5GBASE-KR (5GE)\n* `10gbase-kr` - 10GBASE-KR (10GE)\n* `10gbase-kx4` - 10GBASE-KX4 (10GE)\n* `25gbase-kr` - 25GBASE-KR (25GE)\n* `40gbase-kr4` - 40GBASE-KR4 (40GE)\n* `50gbase-kr` - 50GBASE-KR (50GE)\n* `100gbase-kp4` - 100GBASE-KP4 (100GE)\n* `100gbase-kr2` - 100GBASE-KR2 (100GE)\n* `100gbase-kr4` - 100GBASE-KR4 (100GE)\n* `1.6tbase-kr8` - 1.6TBASE-KR8 (1.6TE)\n* `ieee802.11a` - IEEE 802.11a\n* `ieee802.11g` - IEEE 802.11b/g\n* `ieee802.11n` - IEEE 802.11n (Wi-Fi 4)\n* `ieee802.11ac` - IEEE 802.11ac (Wi-Fi 5)\n* `ieee802.11ad` - IEEE 802.11ad (WiGig)\n* `ieee802.11ax` - IEEE 802.11ax (Wi-Fi 6)\n* `ieee802.11ay` - IEEE 802.11ay (WiGig)\n* `ieee802.11be` - IEEE 802.11be (Wi-Fi 7)\n* `ieee802.15.1` - IEEE 802.15.1 (Bluetooth)\n* `ieee802.15.4` - IEEE 802.15.4 (LR-WPAN)\n* `other-wireless` - Other (Wireless)\n* `gsm` - GSM\n* `cdma` - CDMA\n* `lte` - LTE\n* `4g` - 4G\n* `5g` - 5G\n* `sonet-oc3` - OC-3/STM-1\n* `sonet-oc12` - OC-12/STM-4\n* `sonet-oc48` - OC-48/STM-16\n* `sonet-oc192` - OC-192/STM-64\n* `sonet-oc768` - OC-768/STM-256\n* `sonet-oc1920` - OC-1920/STM-640\n* `sonet-oc3840` - OC-3840/STM-1234\n* `1gfc-sfp` - SFP (1GFC)\n* `2gfc-sfp` - SFP (2GFC)\n* `4gfc-sfp` - SFP (4GFC)\n* `8gfc-sfpp` - SFP+ (8GFC)\n* `16gfc-sfpp` - SFP+ (16GFC)\n* `32gfc-sfp28` - SFP28 (32GFC)\n* `32gfc-sfpp` - SFP+ (32GFC)\n* `64gfc-qsfpp` - QSFP+ (64GFC)\n* `64gfc-sfpdd` - SFP-DD (64GFC)\n* `64gfc-sfpp` - SFP+ (64GFC)\n* `128gfc-qsfp28` - QSFP28 (128GFC)\n* `infiniband-sdr` - SDR (2 Gbps)\n* `infiniband-ddr` - DDR (4 Gbps)\n* `infiniband-qdr` - QDR (8 Gbps)\n* `infiniband-fdr10` - FDR10 (10 Gbps)\n* `infiniband-fdr` - FDR (13.5 Gbps)\n* `infiniband-edr` - EDR (25 Gbps)\n* `infiniband-hdr` - HDR (50 Gbps)\n* `infiniband-ndr` - NDR (100 Gbps)\n* `infiniband-xdr` - XDR (200 Gbps)\n* `infiniband-sdr-4x` - SDR 4X (8 Gbps)\n* `infiniband-ddr-4x` - DDR 4X (16 Gbps)\n* `infiniband-qdr-4x` - QDR 4X (32 Gbps)\n* `infiniband-fdr10-4x` - FDR10 4X (40 Gbps)\n* `infiniband-fdr-4x` - FDR 4X (56 Gbps)\n* `infiniband-edr-4x` - EDR 4X (100 Gbps)\n* `infiniband-hdr-4x` - HDR 4X (200 Gbps)\n* `infiniband-ndr-4x` - NDR 4X (400 Gbps)\n* `infiniband-xdr-4x` - XDR 4X (800 Gbps)\n* `t1` - T1 (1.544 Mbps)\n* `e1` - E1 (2.048 Mbps)\n* `t3` - T3 (45 Mbps)\n* `e3` - E3 (34 Mbps)\n* `xdsl` - xDSL\n* `docsis` - DOCSIS\n* `moca` - MoCA\n* `bpon` - BPON (622 Mbps / 155 Mbps)\n* `epon` - EPON (1 Gbps)\n* `10g-epon` - 10G-EPON (10 Gbps)\n* `gpon` - GPON (2.5 Gbps / 1.25 Gbps)\n* `xg-pon` - XG-PON (10 Gbps / 2.5 Gbps)\n* `xgs-pon` - XGS-PON (10 Gbps)\n* `ng-pon2` - NG-PON2 (TWDM-PON) (4x10 Gbps)\n* `25g-pon` - 25G-PON (25 Gbps)\n* `50g-pon` - 50G-PON (50 Gbps)\n* `cisco-stackwise` - Cisco StackWise\n* `cisco-stackwise-plus` - Cisco StackWise Plus\n* `cisco-flexstack` - Cisco FlexStack\n* `cisco-flexstack-plus` - Cisco FlexStack Plus\n* `cisco-stackwise-80` - Cisco StackWise-80\n* `cisco-stackwise-160` - Cisco StackWise-160\n* `cisco-stackwise-320` - Cisco StackWise-320\n* `cisco-stackwise-480` - Cisco StackWise-480\n* `cisco-stackwise-1t` - Cisco StackWise-1T\n* `juniper-vcp` - Juniper VCP\n* `extreme-summitstack` - Extreme SummitStack\n* `extreme-summitstack-128` - Extreme SummitStack-128\n* `extreme-summitstack-256` - Extreme SummitStack-256\n* `extreme-summitstack-512` - Extreme SummitStack-512\n* `hpe-synergy-interconnect-link` - HPE Synergy Interconnect Link\n* `other` - Other", + "x-spec-enum-id": "19cc901fcea417ad" + }, + "channels": { + "type": "integer", + "maximum": 1024, + "minimum": 1, + "nullable": true, + "description": "The number of channels into which this interface is channelized" + }, + "channel_id": { + "type": "integer", + "maximum": 1024, + "minimum": 1, + "nullable": true, + "description": "The channel on the parent interface to which this subinterface is bound" }, "enabled": { "type": "boolean" @@ -273403,6 +306765,14 @@ "type": "string", "maxLength": 200 }, + "parent": { + "allOf": [ + { + "$ref": "#/components/schemas/NestedInterfaceTemplateRequest" + } + ], + "nullable": true + }, "bridge": { "allOf": [ { @@ -274209,6 +307579,11 @@ "format": "date-time", "nullable": true }, + "execution_time": { + "type": "string", + "readOnly": true, + "nullable": true + }, "user": { "allOf": [ { @@ -274267,6 +307642,7 @@ "display", "display_url", "error", + "execution_time", "id", "job_id", "name", @@ -275141,6 +308517,10 @@ "readOnly": true, "nullable": true }, + "is_primary": { + "type": "boolean", + "readOnly": true + }, "description": { "type": "string", "maxLength": 200 @@ -275185,6 +308565,7 @@ "display", "display_url", "id", + "is_primary", "last_updated", "mac_address", "url" @@ -275512,6 +308893,10 @@ "format": "date-time", "readOnly": true, "nullable": true + }, + "is_bay_compatible": { + "type": "boolean", + "readOnly": true } }, "required": [ @@ -275520,6 +308905,7 @@ "display", "display_url", "id", + "is_bay_compatible", "last_updated", "module_bay", "module_type", @@ -275580,6 +308966,12 @@ "type": "string", "maxLength": 200 }, + "module_bay_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BriefModuleBayType" + } + }, "installed_module": { "allOf": [ { @@ -275622,6 +309014,10 @@ "type": "boolean", "readOnly": true, "title": " occupied" + }, + "is_module_compatible": { + "type": "boolean", + "readOnly": true } }, "required": [ @@ -275631,6 +309027,7 @@ "display", "display_url", "id", + "is_module_compatible", "last_updated", "name", "url" @@ -275688,6 +309085,12 @@ "type": "string", "maxLength": 200 }, + "module_bay_types": { + "type": "array", + "items": { + "type": "integer" + } + }, "installed_module": { "oneOf": [ { @@ -275791,6 +309194,12 @@ "type": "string", "maxLength": 200 }, + "module_bay_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BriefModuleBayType" + } + }, "created": { "type": "string", "format": "date-time", @@ -275871,12 +309280,202 @@ "description": { "type": "string", "maxLength": 200 + }, + "module_bay_types": { + "type": "array", + "items": { + "type": "integer" + } } }, "required": [ "name" ] }, + "ModuleBayType": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "display_url": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "display": { + "type": "string", + "readOnly": true + }, + "name": { + "type": "string", + "maxLength": 100 + }, + "slug": { + "type": "string", + "maxLength": 100, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "manufacturer": { + "allOf": [ + { + "$ref": "#/components/schemas/BriefManufacturer" + } + ], + "nullable": true + }, + "color": { + "oneOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{6}$", + "maxLength": 6 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwner" + } + ], + "nullable": true + }, + "comments": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTag" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + }, + "created": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "last_updated": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + } + }, + "required": [ + "created", + "display", + "display_url", + "id", + "last_updated", + "name", + "slug", + "url" + ] + }, + "ModuleBayTypeRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "manufacturer": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefManufacturerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "color": { + "oneOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{6}$", + "maxLength": 6 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "comments": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "name", + "slug" + ] + }, "ModuleRequest": { "type": "object", "description": "Base serializer class for models inheriting from PrimaryModel.", @@ -276054,6 +309653,34 @@ }, "nullable": true }, + "cooling_method": { + "type": "object", + "properties": { + "value": { + "enum": [ + "air", + "liquid", + "hybrid", + "immersion", + "", + null + ], + "type": "string", + "description": "* `air` - Air\n* `liquid` - Liquid\n* `hybrid` - Hybrid\n* `immersion` - Immersion", + "x-spec-enum-id": "fff5375d415a4fdc" + }, + "label": { + "type": "string", + "enum": [ + "Air", + "Liquid", + "Hybrid", + "Immersion" + ] + } + }, + "nullable": true + }, "weight": { "type": "number", "format": "double", @@ -276091,6 +309718,12 @@ }, "nullable": true }, + "end_of_life": { + "type": "string", + "format": "date", + "nullable": true, + "description": "The date after which this module type is no longer supported by the manufacturer" + }, "description": { "type": "string", "maxLength": 200 @@ -276098,6 +309731,12 @@ "attributes": { "nullable": true }, + "module_bay_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BriefModuleBayType" + } + }, "owner": { "allOf": [ { @@ -276151,6 +309790,14 @@ "type": "integer", "readOnly": true }, + "cooling_intake_template_count": { + "type": "integer", + "readOnly": true + }, + "cooling_outflow_template_count": { + "type": "integer", + "readOnly": true + }, "interface_template_count": { "type": "integer", "readOnly": true @@ -276171,6 +309818,8 @@ "required": [ "console_port_template_count", "console_server_port_template_count", + "cooling_intake_template_count", + "cooling_outflow_template_count", "created", "display", "display_url", @@ -276371,6 +310020,20 @@ "x-spec-enum-id": "5ad4e700c656b09d", "nullable": true }, + "cooling_method": { + "enum": [ + "air", + "liquid", + "hybrid", + "immersion", + "", + null + ], + "type": "string", + "description": "* `air` - Air\n* `liquid` - Liquid\n* `hybrid` - Hybrid\n* `immersion` - Immersion", + "x-spec-enum-id": "fff5375d415a4fdc", + "nullable": true + }, "weight": { "type": "number", "format": "double", @@ -276394,6 +310057,12 @@ "x-spec-enum-id": "2235ce3f404afbc0", "nullable": true }, + "end_of_life": { + "type": "string", + "format": "date", + "nullable": true, + "description": "The date after which this module type is no longer supported by the manufacturer" + }, "description": { "type": "string", "maxLength": 200 @@ -276401,6 +310070,12 @@ "attributes": { "nullable": true }, + "module_bay_types": { + "type": "array", + "items": { + "type": "integer" + } + }, "owner": { "oneOf": [ { @@ -276504,6 +310179,64 @@ "slug" ] }, + "NestedCoolingOutflow": { + "type": "object", + "description": "Represents an object related through a ForeignKey field. On write, it accepts a primary key (PK) value or a\ndictionary of attributes which can be used to uniquely identify the related object. This class should be\nsubclassed to return a full representation of the related object on read.", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "display_url": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "display": { + "type": "string", + "readOnly": true + }, + "device": { + "allOf": [ + { + "$ref": "#/components/schemas/NestedDevice" + } + ], + "readOnly": true + }, + "name": { + "type": "string", + "maxLength": 64 + } + }, + "required": [ + "device", + "display", + "display_url", + "id", + "name", + "url" + ] + }, + "NestedCoolingOutflowRequest": { + "type": "object", + "description": "Represents an object related through a ForeignKey field. On write, it accepts a primary key (PK) value or a\ndictionary of attributes which can be used to uniquely identify the related object. This class should be\nsubclassed to return a full representation of the related object on read.", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "required": [ + "name" + ] + }, "NestedDevice": { "type": "object", "description": "Represents an object related through a ForeignKey field. On write, it accepts a primary key (PK) value or a\ndictionary of attributes which can be used to uniquely identify the related object. This class should be\nsubclassed to return a full representation of the related object on read.", @@ -279111,6 +312844,192 @@ } } }, + "PaginatedCoolingFeedList": { + "type": "object", + "required": [ + "count", + "results" + ], + "properties": { + "count": { + "type": "integer", + "example": 123 + }, + "next": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?offset=400&limit=100" + }, + "previous": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?offset=200&limit=100" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingFeed" + } + } + } + }, + "PaginatedCoolingIntakeList": { + "type": "object", + "required": [ + "count", + "results" + ], + "properties": { + "count": { + "type": "integer", + "example": 123 + }, + "next": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?offset=400&limit=100" + }, + "previous": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?offset=200&limit=100" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingIntake" + } + } + } + }, + "PaginatedCoolingIntakeTemplateList": { + "type": "object", + "required": [ + "count", + "results" + ], + "properties": { + "count": { + "type": "integer", + "example": 123 + }, + "next": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?offset=400&limit=100" + }, + "previous": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?offset=200&limit=100" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingIntakeTemplate" + } + } + } + }, + "PaginatedCoolingOutflowList": { + "type": "object", + "required": [ + "count", + "results" + ], + "properties": { + "count": { + "type": "integer", + "example": 123 + }, + "next": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?offset=400&limit=100" + }, + "previous": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?offset=200&limit=100" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingOutflow" + } + } + } + }, + "PaginatedCoolingOutflowTemplateList": { + "type": "object", + "required": [ + "count", + "results" + ], + "properties": { + "count": { + "type": "integer", + "example": 123 + }, + "next": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?offset=400&limit=100" + }, + "previous": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?offset=200&limit=100" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingOutflowTemplate" + } + } + } + }, + "PaginatedCoolingSourceList": { + "type": "object", + "required": [ + "count", + "results" + ], + "properties": { + "count": { + "type": "integer", + "example": 123 + }, + "next": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?offset=400&limit=100" + }, + "previous": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?offset=200&limit=100" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoolingSource" + } + } + } + }, "PaginatedCustomFieldChoiceSetList": { "type": "object", "required": [ @@ -279328,6 +313247,37 @@ } } }, + "PaginatedDeviceList": { + "type": "object", + "required": [ + "count", + "results" + ], + "properties": { + "count": { + "type": "integer", + "example": 123 + }, + "next": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?offset=400&limit=100" + }, + "previous": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?offset=200&limit=100" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Device" + } + } + } + }, "PaginatedDeviceRoleList": { "type": "object", "required": [ @@ -279390,37 +313340,6 @@ } } }, - "PaginatedDeviceWithConfigContextList": { - "type": "object", - "required": [ - "count", - "results" - ], - "properties": { - "count": { - "type": "integer", - "example": 123 - }, - "next": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?offset=400&limit=100" - }, - "previous": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?offset=200&limit=100" - }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DeviceWithConfigContext" - } - } - } - }, "PaginatedEventRuleList": { "type": "object", "required": [ @@ -280320,6 +314239,37 @@ } } }, + "PaginatedModuleBayTypeList": { + "type": "object", + "required": [ + "count", + "results" + ], + "properties": { + "count": { + "type": "integer", + "example": 123 + }, + "next": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?offset=400&limit=100" + }, + "previous": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?offset=200&limit=100" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModuleBayType" + } + } + } + }, "PaginatedModuleList": { "type": "object", "required": [ @@ -282242,6 +316192,37 @@ } } }, + "PaginatedVirtualMachineList": { + "type": "object", + "required": [ + "count", + "results" + ], + "properties": { + "count": { + "type": "integer", + "example": 123 + }, + "next": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?offset=400&limit=100" + }, + "previous": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?offset=200&limit=100" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VirtualMachine" + } + } + } + }, "PaginatedVirtualMachineTypeList": { "type": "object", "required": [ @@ -282273,37 +316254,6 @@ } } }, - "PaginatedVirtualMachineWithConfigContextList": { - "type": "object", - "required": [ - "count", - "results" - ], - "properties": { - "count": { - "type": "integer", - "example": 123 - }, - "next": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?offset=400&limit=100" - }, - "previous": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?offset=200&limit=100" - }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/VirtualMachineWithConfigContext" - } - } - } - }, "PaginatedWebhookList": { "type": "object", "required": [ @@ -284877,6 +318827,769 @@ "id" ] }, + "PatchedBulkCoolingFeedRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "id": { + "type": "integer" + }, + "cooling_source": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefCoolingSourceRequest" + } + ] + }, + "rack": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefRackRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "status": { + "enum": [ + "offline", + "active", + "planned", + "failed" + ], + "type": "string", + "description": "* `offline` - Offline\n* `active` - Active\n* `planned` - Planned\n* `failed` - Failed", + "x-spec-enum-id": "ec530572dc778583" + }, + "cooling_capacity": { + "type": "number", + "format": "double", + "maximum": 100000000, + "minimum": 0, + "exclusiveMaximum": true, + "nullable": true, + "description": "Rated cooling capacity (kW)" + }, + "max_flow": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "max_flow_unit": { + "enum": [ + "lpm", + "m3ph", + "gpm", + "", + null + ], + "type": "string", + "description": "* `lpm` - Liters per minute (L/min)\n* `m3ph` - Cubic meters per hour (m³/h)\n* `gpm` - Gallons per minute (GPM)", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "tenant": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefTenantRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "comments": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "id" + ] + }, + "PatchedBulkCoolingIntakeRequest": { + "type": "object", + "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", + "properties": { + "id": { + "type": "integer" + }, + "device": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefDeviceRequest" + } + ] + }, + "module": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefModuleRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "label": { + "type": "string", + "description": "Physical label", + "maxLength": 64 + }, + "type": { + "enum": [ + "uqd", + "uqdb", + "qdc", + "camlock", + "npt", + "bsp", + "proprietary", + "", + null + ], + "type": "string", + "description": "* `uqd` - UQD (Universal Quick Disconnect)\n* `uqdb` - UQDB (Universal Quick Disconnect, Blind-mate)\n* `qdc` - QDC (Quick Disconnect Coupling)\n* `camlock` - Camlock (cam-and-groove)\n* `npt` - NPT (threaded)\n* `bsp` - BSP (threaded)\n* `proprietary` - Proprietary", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + }, + "diameter": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "diameter_unit": { + "enum": [ + "mm", + "cm", + "in", + "", + null + ], + "type": "string", + "description": "* `mm` - Millimeters\n* `cm` - Centimeters\n* `in` - Inches", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + }, + "max_flow": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "max_flow_unit": { + "enum": [ + "lpm", + "m3ph", + "gpm", + "", + null + ], + "type": "string", + "description": "* `lpm` - Liters per minute (L/min)\n* `m3ph` - Cubic meters per hour (m³/h)\n* `gpm` - Gallons per minute (GPM)", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + }, + "cooling_outflow": { + "allOf": [ + { + "$ref": "#/components/schemas/NestedCoolingOutflowRequest" + } + ], + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "id" + ] + }, + "PatchedBulkCoolingIntakeTemplateRequest": { + "type": "object", + "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)", + "properties": { + "id": { + "type": "integer" + }, + "device_type": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefDeviceTypeRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "module_type": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefModuleTypeRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "description": "{module} is accepted as a substitution for the module bay position when attached to a module type.", + "maxLength": 64 + }, + "label": { + "type": "string", + "description": "Physical label", + "maxLength": 64 + }, + "type": { + "enum": [ + "uqd", + "uqdb", + "qdc", + "camlock", + "npt", + "bsp", + "proprietary", + "", + null + ], + "type": "string", + "description": "* `uqd` - UQD (Universal Quick Disconnect)\n* `uqdb` - UQDB (Universal Quick Disconnect, Blind-mate)\n* `qdc` - QDC (Quick Disconnect Coupling)\n* `camlock` - Camlock (cam-and-groove)\n* `npt` - NPT (threaded)\n* `bsp` - BSP (threaded)\n* `proprietary` - Proprietary", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + }, + "diameter": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "diameter_unit": { + "enum": [ + "mm", + "cm", + "in", + "", + null + ], + "type": "string", + "description": "* `mm` - Millimeters\n* `cm` - Centimeters\n* `in` - Inches", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + }, + "max_flow": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "max_flow_unit": { + "enum": [ + "lpm", + "m3ph", + "gpm", + "", + null + ], + "type": "string", + "description": "* `lpm` - Liters per minute (L/min)\n* `m3ph` - Cubic meters per hour (m³/h)\n* `gpm` - Gallons per minute (GPM)", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + } + }, + "required": [ + "id" + ] + }, + "PatchedBulkCoolingOutflowRequest": { + "type": "object", + "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", + "properties": { + "id": { + "type": "integer" + }, + "device": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefDeviceRequest" + } + ] + }, + "module": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefModuleRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "label": { + "type": "string", + "description": "Physical label", + "maxLength": 64 + }, + "type": { + "enum": [ + "uqd", + "uqdb", + "qdc", + "camlock", + "npt", + "bsp", + "proprietary", + "", + null + ], + "type": "string", + "description": "* `uqd` - UQD (Universal Quick Disconnect)\n* `uqdb` - UQDB (Universal Quick Disconnect, Blind-mate)\n* `qdc` - QDC (Quick Disconnect Coupling)\n* `camlock` - Camlock (cam-and-groove)\n* `npt` - NPT (threaded)\n* `bsp` - BSP (threaded)\n* `proprietary` - Proprietary", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + }, + "diameter": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "diameter_unit": { + "enum": [ + "mm", + "cm", + "in", + "", + null + ], + "type": "string", + "description": "* `mm` - Millimeters\n* `cm` - Centimeters\n* `in` - Inches", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + }, + "cooling_intake": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefCoolingIntakeRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "id" + ] + }, + "PatchedBulkCoolingOutflowTemplateRequest": { + "type": "object", + "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)", + "properties": { + "id": { + "type": "integer" + }, + "device_type": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefDeviceTypeRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "module_type": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefModuleTypeRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "description": "{module} is accepted as a substitution for the module bay position when attached to a module type.", + "maxLength": 64 + }, + "label": { + "type": "string", + "description": "Physical label", + "maxLength": 64 + }, + "type": { + "enum": [ + "uqd", + "uqdb", + "qdc", + "camlock", + "npt", + "bsp", + "proprietary", + "", + null + ], + "type": "string", + "description": "* `uqd` - UQD (Universal Quick Disconnect)\n* `uqdb` - UQDB (Universal Quick Disconnect, Blind-mate)\n* `qdc` - QDC (Quick Disconnect Coupling)\n* `camlock` - Camlock (cam-and-groove)\n* `npt` - NPT (threaded)\n* `bsp` - BSP (threaded)\n* `proprietary` - Proprietary", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + }, + "diameter": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "diameter_unit": { + "enum": [ + "mm", + "cm", + "in", + "", + null + ], + "type": "string", + "description": "* `mm` - Millimeters\n* `cm` - Centimeters\n* `in` - Inches", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + }, + "cooling_intake": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefCoolingIntakeTemplateRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + } + }, + "required": [ + "id" + ] + }, + "PatchedBulkCoolingSourceRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "id": { + "type": "integer" + }, + "site": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefSiteRequest" + } + ] + }, + "location": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefLocationRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "type": { + "enum": [ + "chiller", + "cooling-tower", + "dry-cooler", + "crac", + "crah" + ], + "type": "string", + "description": "* `chiller` - Chiller\n* `cooling-tower` - Cooling tower\n* `dry-cooler` - Dry cooler\n* `crac` - CRAC\n* `crah` - CRAH", + "x-spec-enum-id": "f225f830b0d77ac5" + }, + "status": { + "enum": [ + "offline", + "active", + "planned", + "failed" + ], + "type": "string", + "description": "* `offline` - Offline\n* `active` - Active\n* `planned` - Planned\n* `failed` - Failed", + "x-spec-enum-id": "ec530572dc778583" + }, + "fluid_type": { + "enum": [ + "water", + "water-glycol", + "dielectric", + "refrigerant", + "", + null + ], + "type": "string", + "description": "* `water` - Water\n* `water-glycol` - Water/glycol\n* `dielectric` - Dielectric\n* `refrigerant` - Refrigerant", + "x-spec-enum-id": "b558e2a3f7349765", + "nullable": true + }, + "cooling_capacity": { + "type": "number", + "format": "double", + "maximum": 100000000, + "minimum": 0, + "exclusiveMaximum": true, + "nullable": true, + "description": "Total rated cooling capacity (kW)" + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "comments": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "id" + ] + }, "PatchedBulkCustomFieldChoiceSetRequest": { "type": "object", "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", @@ -284992,8 +319705,8 @@ "multiobject" ], "type": "string", - "description": "* `text` - Text\n* `longtext` - Text (long)\n* `integer` - Integer\n* `decimal` - Decimal\n* `boolean` - Boolean (true/false)\n* `date` - Date\n* `datetime` - Date & time\n* `url` - URL\n* `json` - JSON\n* `select` - Selection\n* `multiselect` - Multiple selection\n* `object` - Object\n* `multiobject` - Multiple objects", - "x-spec-enum-id": "47c52a3d983e924c" + "description": "* `text` - Text\n* `longtext` - Text (long)\n* `integer` - Integer\n* `decimal` - Decimal\n* `boolean` - Boolean\n* `date` - Date\n* `datetime` - Date & time\n* `url` - URL\n* `json` - JSON\n* `select` - Selection\n* `multiselect` - Multiple selection\n* `object` - Object\n* `multiobject` - Multiple objects", + "x-spec-enum-id": "6ec6eff91d34cc44" }, "related_object_type": { "type": "string", @@ -285069,6 +319782,10 @@ "type": "boolean", "description": "Replicate this value when cloning objects" }, + "nulls_first": { + "type": "boolean", + "description": "Sort null values before non-null values when ordering by this field" + }, "default": { "nullable": true, "description": "Default value for the field (must be a JSON value). Encapsulate strings with double quotes (e.g. \"Foo\")." @@ -285451,6 +320168,356 @@ "id" ] }, + "PatchedBulkDeviceRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string", + "nullable": true, + "maxLength": 64 + }, + "device_type": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefDeviceTypeRequest" + } + ] + }, + "role": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefDeviceRoleRequest" + } + ] + }, + "tenant": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefTenantRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "platform": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefPlatformRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "serial": { + "type": "string", + "title": "Serial number", + "description": "Chassis serial number, assigned by the manufacturer", + "maxLength": 50 + }, + "asset_tag": { + "type": "string", + "nullable": true, + "description": "A unique tag used to identify this device", + "maxLength": 50 + }, + "site": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefSiteRequest" + } + ] + }, + "location": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefLocationRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "rack": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefRackRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "position": { + "type": "number", + "format": "double", + "maximum": 1000, + "minimum": 0.5, + "exclusiveMaximum": true, + "nullable": true, + "title": "Position (U)" + }, + "face": { + "enum": [ + "front", + "rear", + "" + ], + "type": "string", + "description": "* `front` - Front\n* `rear` - Rear", + "x-spec-enum-id": "d2fb9b3f75158b83" + }, + "latitude": { + "type": "number", + "format": "double", + "maximum": 90.0, + "minimum": -90.0, + "nullable": true, + "description": "GPS coordinate in decimal format (xx.yyyyyy)" + }, + "longitude": { + "type": "number", + "format": "double", + "maximum": 180.0, + "minimum": -180.0, + "nullable": true, + "description": "GPS coordinate in decimal format (xx.yyyyyy)" + }, + "status": { + "enum": [ + "offline", + "active", + "planned", + "staged", + "failed", + "inventory", + "decommissioning" + ], + "type": "string", + "description": "* `offline` - Offline\n* `active` - Active\n* `planned` - Planned\n* `staged` - Staged\n* `failed` - Failed\n* `inventory` - Inventory\n* `decommissioning` - Decommissioning", + "x-spec-enum-id": "65feb4244cc9110c" + }, + "airflow": { + "enum": [ + "front-to-rear", + "rear-to-front", + "left-to-right", + "right-to-left", + "side-to-rear", + "rear-to-side", + "bottom-to-top", + "top-to-bottom", + "passive", + "mixed", + "" + ], + "type": "string", + "description": "* `front-to-rear` - Front to rear\n* `rear-to-front` - Rear to front\n* `left-to-right` - Left to right\n* `right-to-left` - Right to left\n* `side-to-rear` - Side to rear\n* `rear-to-side` - Rear to side\n* `bottom-to-top` - Bottom to top\n* `top-to-bottom` - Top to bottom\n* `passive` - Passive\n* `mixed` - Mixed", + "x-spec-enum-id": "11cb3d363b41ba9e" + }, + "cooling_method": { + "enum": [ + "air", + "liquid", + "hybrid", + "immersion", + "", + null + ], + "type": "string", + "description": "* `air` - Air\n* `liquid` - Liquid\n* `hybrid` - Hybrid\n* `immersion` - Immersion", + "x-spec-enum-id": "fff5375d415a4fdc", + "nullable": true + }, + "primary_ip4": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefIPAddressRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "primary_ip6": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefIPAddressRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "oob_ip": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefIPAddressRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "cluster": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefClusterRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "virtual_chassis": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefVirtualChassisRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "vc_position": { + "type": "integer", + "maximum": 255, + "minimum": 0, + "nullable": true + }, + "vc_priority": { + "type": "integer", + "maximum": 255, + "minimum": 0, + "nullable": true, + "description": "Virtual chassis master election priority" + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "comments": { + "type": "string" + }, + "config_template": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefConfigTemplateRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "local_context_data": { + "nullable": true, + "description": "Local config context data takes precedence over source contexts in the final rendered config context" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "id" + ] + }, "PatchedBulkDeviceRoleRequest": { "type": "object", "description": "Base serializer class for models inheriting from NestedGroupModel.", @@ -285639,6 +320706,20 @@ "x-spec-enum-id": "11cb3d363b41ba9e", "nullable": true }, + "cooling_method": { + "enum": [ + "air", + "liquid", + "hybrid", + "immersion", + "", + null + ], + "type": "string", + "description": "* `air` - Air\n* `liquid` - Liquid\n* `hybrid` - Hybrid\n* `immersion` - Immersion", + "x-spec-enum-id": "fff5375d415a4fdc", + "nullable": true + }, "weight": { "type": "number", "format": "double", @@ -285662,6 +320743,12 @@ "x-spec-enum-id": "2235ce3f404afbc0", "nullable": true }, + "end_of_life": { + "type": "string", + "format": "date", + "nullable": true, + "description": "The date after which this device type is no longer supported by the manufacturer" + }, "front_image": { "type": "string", "format": "binary", @@ -285710,342 +320797,6 @@ "id" ] }, - "PatchedBulkDeviceWithConfigContextRequest": { - "type": "object", - "description": "Base serializer class for models inheriting from PrimaryModel.", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string", - "nullable": true, - "maxLength": 64 - }, - "device_type": { - "oneOf": [ - { - "type": "integer" - }, - { - "$ref": "#/components/schemas/BriefDeviceTypeRequest" - } - ] - }, - "role": { - "oneOf": [ - { - "type": "integer" - }, - { - "$ref": "#/components/schemas/BriefDeviceRoleRequest" - } - ] - }, - "tenant": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefTenantRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "platform": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefPlatformRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "serial": { - "type": "string", - "title": "Serial number", - "description": "Chassis serial number, assigned by the manufacturer", - "maxLength": 50 - }, - "asset_tag": { - "type": "string", - "nullable": true, - "description": "A unique tag used to identify this device", - "maxLength": 50 - }, - "site": { - "oneOf": [ - { - "type": "integer" - }, - { - "$ref": "#/components/schemas/BriefSiteRequest" - } - ] - }, - "location": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefLocationRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "rack": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefRackRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "position": { - "type": "number", - "format": "double", - "maximum": 1000, - "minimum": 0.5, - "exclusiveMaximum": true, - "nullable": true, - "title": "Position (U)" - }, - "face": { - "enum": [ - "front", - "rear", - "" - ], - "type": "string", - "description": "* `front` - Front\n* `rear` - Rear", - "x-spec-enum-id": "d2fb9b3f75158b83" - }, - "latitude": { - "type": "number", - "format": "double", - "maximum": 90.0, - "minimum": -90.0, - "nullable": true, - "description": "GPS coordinate in decimal format (xx.yyyyyy)" - }, - "longitude": { - "type": "number", - "format": "double", - "maximum": 180.0, - "minimum": -180.0, - "nullable": true, - "description": "GPS coordinate in decimal format (xx.yyyyyy)" - }, - "status": { - "enum": [ - "offline", - "active", - "planned", - "staged", - "failed", - "inventory", - "decommissioning" - ], - "type": "string", - "description": "* `offline` - Offline\n* `active` - Active\n* `planned` - Planned\n* `staged` - Staged\n* `failed` - Failed\n* `inventory` - Inventory\n* `decommissioning` - Decommissioning", - "x-spec-enum-id": "65feb4244cc9110c" - }, - "airflow": { - "enum": [ - "front-to-rear", - "rear-to-front", - "left-to-right", - "right-to-left", - "side-to-rear", - "rear-to-side", - "bottom-to-top", - "top-to-bottom", - "passive", - "mixed", - "" - ], - "type": "string", - "description": "* `front-to-rear` - Front to rear\n* `rear-to-front` - Rear to front\n* `left-to-right` - Left to right\n* `right-to-left` - Right to left\n* `side-to-rear` - Side to rear\n* `rear-to-side` - Rear to side\n* `bottom-to-top` - Bottom to top\n* `top-to-bottom` - Top to bottom\n* `passive` - Passive\n* `mixed` - Mixed", - "x-spec-enum-id": "11cb3d363b41ba9e" - }, - "primary_ip4": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefIPAddressRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "primary_ip6": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefIPAddressRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "oob_ip": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefIPAddressRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "cluster": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefClusterRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "virtual_chassis": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefVirtualChassisRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "vc_position": { - "type": "integer", - "maximum": 255, - "minimum": 0, - "nullable": true - }, - "vc_priority": { - "type": "integer", - "maximum": 255, - "minimum": 0, - "nullable": true, - "description": "Virtual chassis master election priority" - }, - "description": { - "type": "string", - "maxLength": 200 - }, - "owner": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefOwnerRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "comments": { - "type": "string" - }, - "config_template": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefConfigTemplateRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "local_context_data": { - "nullable": true, - "description": "Local config context data takes precedence over source contexts in the final rendered config context" - }, - "tags": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NestedTagRequest" - } - }, - "custom_fields": { - "type": "object", - "additionalProperties": {} - } - }, - "required": [ - "id" - ] - }, "PatchedBulkEventRuleRequest": { "type": "object", "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", @@ -286100,7 +320851,8 @@ "x-spec-enum-id": "287901b937995956" }, "action_object_type": { - "type": "string" + "type": "string", + "nullable": true }, "action_object_id": { "type": "integer", @@ -287463,7 +322215,7 @@ }, "PatchedBulkInterfaceRequest": { "type": "object", - "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", + "description": "Mixin for Interface and VMInterface serializers that adds a `mac_address` shortcut field for\ncreating/updating the primary MACAddress in a single request. The validated write is centralized\non the interface model (BaseInterface.set_primary_mac_address); this mixin is a thin adapter that\nowns the permission check, the shortcut-vs-primary_mac_address conflict guard, and translating the\nmodel's validation errors into API errors.", "properties": { "id": { "type": "integer" @@ -287515,6 +322267,7 @@ "virtual", "bridge", "lag", + "channel", "100base-fx", "100base-lfx", "100base-tx", @@ -287741,8 +322494,22 @@ "other" ], "type": "string", - "description": "* `virtual` - Virtual\n* `bridge` - Bridge\n* `lag` - Link Aggregation Group (LAG)\n* `100base-fx` - 100BASE-FX (10/100ME)\n* `100base-lfx` - 100BASE-LFX (10/100ME)\n* `100base-tx` - 100BASE-TX (10/100ME)\n* `100base-t1` - 100BASE-T1 (10/100ME)\n* `1000base-bx10-d` - 1000BASE-BX10-D (1GE BiDi Down)\n* `1000base-bx10-u` - 1000BASE-BX10-U (1GE BiDi Up)\n* `1000base-cwdm` - 1000BASE-CWDM (1GE)\n* `1000base-cx` - 1000BASE-CX (1GE DAC)\n* `1000base-dwdm` - 1000BASE-DWDM (1GE)\n* `1000base-ex` - 1000BASE-EX (1GE)\n* `1000base-lsx` - 1000BASE-LSX (1GE)\n* `1000base-lx` - 1000BASE-LX (1GE)\n* `1000base-lx10` - 1000BASE-LX10/LH (1GE)\n* `1000base-sx` - 1000BASE-SX (1GE)\n* `1000base-t` - 1000BASE-T (1GE)\n* `1000base-tx` - 1000BASE-TX (1GE)\n* `1000base-zx` - 1000BASE-ZX (1GE)\n* `2.5gbase-t` - 2.5GBASE-T (2.5GE)\n* `5gbase-t` - 5GBASE-T (5GE)\n* `10gbase-br-d` - 10GBASE-BR-D (10GE BiDi Down)\n* `10gbase-br-u` - 10GBASE-BR-U (10GE BiDi Up)\n* `10gbase-cu` - 10GBASE-CU (10GE DAC Passive Twinax)\n* `10gbase-cx4` - 10GBASE-CX4 (10GE DAC)\n* `10gbase-er` - 10GBASE-ER (10GE)\n* `10gbase-lr` - 10GBASE-LR (10GE)\n* `10gbase-lrm` - 10GBASE-LRM (10GE)\n* `10gbase-lx4` - 10GBASE-LX4 (10GE)\n* `10gbase-sr` - 10GBASE-SR (10GE)\n* `10gbase-t` - 10GBASE-T (10GE)\n* `10gbase-zr` - 10GBASE-ZR (10GE)\n* `25gbase-cr` - 25GBASE-CR (25GE DAC)\n* `25gbase-er` - 25GBASE-ER (25GE)\n* `25gbase-lr` - 25GBASE-LR (25GE)\n* `25gbase-sr` - 25GBASE-SR (25GE)\n* `25gbase-t` - 25GBASE-T (25GE)\n* `40gbase-cr4` - 40GBASE-CR4 (40GE DAC)\n* `40gbase-er4` - 40GBASE-ER4 (40GE)\n* `40gbase-fr4` - 40GBASE-FR4 (40GE)\n* `40gbase-lr4` - 40GBASE-LR4 (40GE)\n* `40gbase-sr4` - 40GBASE-SR4 (40GE)\n* `40gbase-sr4-bd` - 40GBASE-SR4 (40GE BiDi)\n* `50gbase-cr` - 50GBASE-CR (50GE DAC)\n* `50gbase-er` - 50GBASE-ER (50GE)\n* `50gbase-fr` - 50GBASE-FR (50GE)\n* `50gbase-lr` - 50GBASE-LR (50GE)\n* `50gbase-sr` - 50GBASE-SR (50GE)\n* `100gbase-cr1` - 100GBASE-CR1 (100GE DAC)\n* `100gbase-cr2` - 100GBASE-CR2 (100GE DAC)\n* `100gbase-cr4` - 100GBASE-CR4 (100GE DAC)\n* `100gbase-cr10` - 100GBASE-CR10 (100GE DAC)\n* `100gbase-cwdm4` - 100GBASE-CWDM4 (100GE)\n* `100gbase-dr` - 100GBASE-DR (100GE)\n* `100gbase-er4` - 100GBASE-ER4 (100GE)\n* `100gbase-fr1` - 100GBASE-FR1 (100GE)\n* `100gbase-lr1` - 100GBASE-LR1 (100GE)\n* `100gbase-lr4` - 100GBASE-LR4 (100GE)\n* `100gbase-sr1` - 100GBASE-SR1 (100GE)\n* `100gbase-sr1.2` - 100GBASE-SR1.2 (100GE BiDi)\n* `100gbase-sr2` - 100GBASE-SR2 (100GE)\n* `100gbase-sr4` - 100GBASE-SR4 (100GE)\n* `100gbase-sr10` - 100GBASE-SR10 (100GE)\n* `100gbase-zr` - 100GBASE-ZR (100GE)\n* `200gbase-cr2` - 200GBASE-CR2 (200GE)\n* `200gbase-cr4` - 200GBASE-CR4 (200GE)\n* `200gbase-dr4` - 200GBASE-DR4 (200GE)\n* `200gbase-er4` - 200GBASE-ER4 (200GE)\n* `200gbase-fr4` - 200GBASE-FR4 (200GE)\n* `200gbase-lr4` - 200GBASE-LR4 (200GE)\n* `200gbase-sr2` - 200GBASE-SR2 (200GE)\n* `200gbase-sr4` - 200GBASE-SR4 (200GE)\n* `200gbase-vr2` - 200GBASE-VR2 (200GE)\n* `400gbase-cr4` - 400GBASE-CR4 (400GE)\n* `400gbase-dr4` - 400GBASE-DR4 (400GE)\n* `400gbase-er8` - 400GBASE-ER8 (400GE)\n* `400gbase-fr4` - 400GBASE-FR4 (400GE)\n* `400gbase-fr8` - 400GBASE-FR8 (400GE)\n* `400gbase-lr4` - 400GBASE-LR4 (400GE)\n* `400gbase-lr8` - 400GBASE-LR8 (400GE)\n* `400gbase-sr4` - 400GBASE-SR4 (400GE)\n* `400gbase-sr4_2` - 400GBASE-SR4.2 (400GE BiDi)\n* `400gbase-sr8` - 400GBASE-SR8 (400GE)\n* `400gbase-sr16` - 400GBASE-SR16 (400GE)\n* `400gbase-vr4` - 400GBASE-VR4 (400GE)\n* `400gbase-zr` - 400GBASE-ZR (400GE)\n* `800gbase-cr8` - 800GBASE-CR8 (800GE)\n* `800gbase-dr8` - 800GBASE-DR8 (800GE)\n* `800gbase-sr8` - 800GBASE-SR8 (800GE)\n* `800gbase-vr8` - 800GBASE-VR8 (800GE)\n* `1.6tbase-cr8` - 1.6TBASE-CR8 (1.6TE)\n* `1.6tbase-dr8` - 1.6TBASE-DR8 (1.6TE)\n* `1.6tbase-dr8-2` - 1.6TBASE-DR8-2 (1.6TE)\n* `100base-x-sfp` - SFP (100ME)\n* `1000base-x-gbic` - GBIC (1GE)\n* `1000base-x-sfp` - SFP (1GE)\n* `2.5gbase-x-sfp` - SFP (2.5GE)\n* `10gbase-x-sfpp` - SFP+ (10GE)\n* `10gbase-x-xenpak` - XENPAK (10GE)\n* `10gbase-x-xfp` - XFP (10GE)\n* `10gbase-x-x2` - X2 (10GE)\n* `25gbase-x-sfp28` - SFP28 (25GE)\n* `40gbase-x-qsfpp` - QSFP+ (40GE)\n* `50gbase-x-sfp28` - QSFP28 (50GE)\n* `50gbase-x-sfp56` - SFP56 (50GE)\n* `100gbase-x-cfp` - CFP (100GE)\n* `100gbase-x-cfp2` - CFP2 (100GE)\n* `100gbase-x-cfp4` - CFP4 (100GE)\n* `100gbase-x-cxp` - CXP (100GE)\n* `100gbase-x-cpak` - Cisco CPAK (100GE)\n* `100gbase-x-dsfp` - DSFP (100GE)\n* `100gbase-x-qsfp28` - QSFP28 (100GE)\n* `100gbase-x-qsfpdd` - QSFP-DD (100GE)\n* `100gbase-x-sfp112` - SFP112 (100GE)\n* `100gbase-x-sfpdd` - SFP-DD (100GE)\n* `200gbase-x-cfp2` - CFP2 (200GE)\n* `200gbase-x-qsfp56` - QSFP56 (200GE)\n* `200gbase-x-qsfpdd` - QSFP-DD (200GE)\n* `400gbase-x-qsfp112` - QSFP112 (400GE)\n* `400gbase-x-qsfpdd` - QSFP-DD (400GE)\n* `400gbase-x-cdfp` - CDFP (400GE)\n* `400gbase-x-cfp2` - CFP2 (400GE)\n* `400gbase-x-cfp8` - CPF8 (400GE)\n* `400gbase-x-osfp` - OSFP (400GE)\n* `400gbase-x-osfp-rhs` - OSFP-RHS (400GE)\n* `800gbase-x-osfp` - OSFP (800GE)\n* `800gbase-x-qsfpdd` - QSFP-DD (800GE)\n* `1.6tbase-x-osfp1600` - OSFP1600 (1.6TE)\n* `1.6tbase-x-osfp1600-rhs` - OSFP1600-RHS (1.6TE)\n* `1.6tbase-x-qsfpdd1600` - QSFP-DD1600 (1.6TE)\n* `1000base-kx` - 1000BASE-KX (1GE)\n* `2.5gbase-kx` - 2.5GBASE-KX (2.5GE)\n* `5gbase-kr` - 5GBASE-KR (5GE)\n* `10gbase-kr` - 10GBASE-KR (10GE)\n* `10gbase-kx4` - 10GBASE-KX4 (10GE)\n* `25gbase-kr` - 25GBASE-KR (25GE)\n* `40gbase-kr4` - 40GBASE-KR4 (40GE)\n* `50gbase-kr` - 50GBASE-KR (50GE)\n* `100gbase-kp4` - 100GBASE-KP4 (100GE)\n* `100gbase-kr2` - 100GBASE-KR2 (100GE)\n* `100gbase-kr4` - 100GBASE-KR4 (100GE)\n* `1.6tbase-kr8` - 1.6TBASE-KR8 (1.6TE)\n* `ieee802.11a` - IEEE 802.11a\n* `ieee802.11g` - IEEE 802.11b/g\n* `ieee802.11n` - IEEE 802.11n (Wi-Fi 4)\n* `ieee802.11ac` - IEEE 802.11ac (Wi-Fi 5)\n* `ieee802.11ad` - IEEE 802.11ad (WiGig)\n* `ieee802.11ax` - IEEE 802.11ax (Wi-Fi 6)\n* `ieee802.11ay` - IEEE 802.11ay (WiGig)\n* `ieee802.11be` - IEEE 802.11be (Wi-Fi 7)\n* `ieee802.15.1` - IEEE 802.15.1 (Bluetooth)\n* `ieee802.15.4` - IEEE 802.15.4 (LR-WPAN)\n* `other-wireless` - Other (Wireless)\n* `gsm` - GSM\n* `cdma` - CDMA\n* `lte` - LTE\n* `4g` - 4G\n* `5g` - 5G\n* `sonet-oc3` - OC-3/STM-1\n* `sonet-oc12` - OC-12/STM-4\n* `sonet-oc48` - OC-48/STM-16\n* `sonet-oc192` - OC-192/STM-64\n* `sonet-oc768` - OC-768/STM-256\n* `sonet-oc1920` - OC-1920/STM-640\n* `sonet-oc3840` - OC-3840/STM-1234\n* `1gfc-sfp` - SFP (1GFC)\n* `2gfc-sfp` - SFP (2GFC)\n* `4gfc-sfp` - SFP (4GFC)\n* `8gfc-sfpp` - SFP+ (8GFC)\n* `16gfc-sfpp` - SFP+ (16GFC)\n* `32gfc-sfp28` - SFP28 (32GFC)\n* `32gfc-sfpp` - SFP+ (32GFC)\n* `64gfc-qsfpp` - QSFP+ (64GFC)\n* `64gfc-sfpdd` - SFP-DD (64GFC)\n* `64gfc-sfpp` - SFP+ (64GFC)\n* `128gfc-qsfp28` - QSFP28 (128GFC)\n* `infiniband-sdr` - SDR (2 Gbps)\n* `infiniband-ddr` - DDR (4 Gbps)\n* `infiniband-qdr` - QDR (8 Gbps)\n* `infiniband-fdr10` - FDR10 (10 Gbps)\n* `infiniband-fdr` - FDR (13.5 Gbps)\n* `infiniband-edr` - EDR (25 Gbps)\n* `infiniband-hdr` - HDR (50 Gbps)\n* `infiniband-ndr` - NDR (100 Gbps)\n* `infiniband-xdr` - XDR (200 Gbps)\n* `infiniband-sdr-4x` - SDR 4X (8 Gbps)\n* `infiniband-ddr-4x` - DDR 4X (16 Gbps)\n* `infiniband-qdr-4x` - QDR 4X (32 Gbps)\n* `infiniband-fdr10-4x` - FDR10 4X (40 Gbps)\n* `infiniband-fdr-4x` - FDR 4X (56 Gbps)\n* `infiniband-edr-4x` - EDR 4X (100 Gbps)\n* `infiniband-hdr-4x` - HDR 4X (200 Gbps)\n* `infiniband-ndr-4x` - NDR 4X (400 Gbps)\n* `infiniband-xdr-4x` - XDR 4X (800 Gbps)\n* `t1` - T1 (1.544 Mbps)\n* `e1` - E1 (2.048 Mbps)\n* `t3` - T3 (45 Mbps)\n* `e3` - E3 (34 Mbps)\n* `xdsl` - xDSL\n* `docsis` - DOCSIS\n* `moca` - MoCA\n* `bpon` - BPON (622 Mbps / 155 Mbps)\n* `epon` - EPON (1 Gbps)\n* `10g-epon` - 10G-EPON (10 Gbps)\n* `gpon` - GPON (2.5 Gbps / 1.25 Gbps)\n* `xg-pon` - XG-PON (10 Gbps / 2.5 Gbps)\n* `xgs-pon` - XGS-PON (10 Gbps)\n* `ng-pon2` - NG-PON2 (TWDM-PON) (4x10 Gbps)\n* `25g-pon` - 25G-PON (25 Gbps)\n* `50g-pon` - 50G-PON (50 Gbps)\n* `cisco-stackwise` - Cisco StackWise\n* `cisco-stackwise-plus` - Cisco StackWise Plus\n* `cisco-flexstack` - Cisco FlexStack\n* `cisco-flexstack-plus` - Cisco FlexStack Plus\n* `cisco-stackwise-80` - Cisco StackWise-80\n* `cisco-stackwise-160` - Cisco StackWise-160\n* `cisco-stackwise-320` - Cisco StackWise-320\n* `cisco-stackwise-480` - Cisco StackWise-480\n* `cisco-stackwise-1t` - Cisco StackWise-1T\n* `juniper-vcp` - Juniper VCP\n* `extreme-summitstack` - Extreme SummitStack\n* `extreme-summitstack-128` - Extreme SummitStack-128\n* `extreme-summitstack-256` - Extreme SummitStack-256\n* `extreme-summitstack-512` - Extreme SummitStack-512\n* `hpe-synergy-interconnect-link` - HPE Synergy Interconnect Link\n* `other` - Other", - "x-spec-enum-id": "b0c97040e5abdff1" + "description": "* `virtual` - Virtual\n* `bridge` - Bridge\n* `lag` - Link Aggregation Group (LAG)\n* `channel` - Channel\n* `100base-fx` - 100BASE-FX (10/100ME)\n* `100base-lfx` - 100BASE-LFX (10/100ME)\n* `100base-tx` - 100BASE-TX (10/100ME)\n* `100base-t1` - 100BASE-T1 (10/100ME)\n* `1000base-bx10-d` - 1000BASE-BX10-D (1GE BiDi Down)\n* `1000base-bx10-u` - 1000BASE-BX10-U (1GE BiDi Up)\n* `1000base-cwdm` - 1000BASE-CWDM (1GE)\n* `1000base-cx` - 1000BASE-CX (1GE DAC)\n* `1000base-dwdm` - 1000BASE-DWDM (1GE)\n* `1000base-ex` - 1000BASE-EX (1GE)\n* `1000base-lsx` - 1000BASE-LSX (1GE)\n* `1000base-lx` - 1000BASE-LX (1GE)\n* `1000base-lx10` - 1000BASE-LX10/LH (1GE)\n* `1000base-sx` - 1000BASE-SX (1GE)\n* `1000base-t` - 1000BASE-T (1GE)\n* `1000base-tx` - 1000BASE-TX (1GE)\n* `1000base-zx` - 1000BASE-ZX (1GE)\n* `2.5gbase-t` - 2.5GBASE-T (2.5GE)\n* `5gbase-t` - 5GBASE-T (5GE)\n* `10gbase-br-d` - 10GBASE-BR-D (10GE BiDi Down)\n* `10gbase-br-u` - 10GBASE-BR-U (10GE BiDi Up)\n* `10gbase-cu` - 10GBASE-CU (10GE DAC Passive Twinax)\n* `10gbase-cx4` - 10GBASE-CX4 (10GE DAC)\n* `10gbase-er` - 10GBASE-ER (10GE)\n* `10gbase-lr` - 10GBASE-LR (10GE)\n* `10gbase-lrm` - 10GBASE-LRM (10GE)\n* `10gbase-lx4` - 10GBASE-LX4 (10GE)\n* `10gbase-sr` - 10GBASE-SR (10GE)\n* `10gbase-t` - 10GBASE-T (10GE)\n* `10gbase-zr` - 10GBASE-ZR (10GE)\n* `25gbase-cr` - 25GBASE-CR (25GE DAC)\n* `25gbase-er` - 25GBASE-ER (25GE)\n* `25gbase-lr` - 25GBASE-LR (25GE)\n* `25gbase-sr` - 25GBASE-SR (25GE)\n* `25gbase-t` - 25GBASE-T (25GE)\n* `40gbase-cr4` - 40GBASE-CR4 (40GE DAC)\n* `40gbase-er4` - 40GBASE-ER4 (40GE)\n* `40gbase-fr4` - 40GBASE-FR4 (40GE)\n* `40gbase-lr4` - 40GBASE-LR4 (40GE)\n* `40gbase-sr4` - 40GBASE-SR4 (40GE)\n* `40gbase-sr4-bd` - 40GBASE-SR4 (40GE BiDi)\n* `50gbase-cr` - 50GBASE-CR (50GE DAC)\n* `50gbase-er` - 50GBASE-ER (50GE)\n* `50gbase-fr` - 50GBASE-FR (50GE)\n* `50gbase-lr` - 50GBASE-LR (50GE)\n* `50gbase-sr` - 50GBASE-SR (50GE)\n* `100gbase-cr1` - 100GBASE-CR1 (100GE DAC)\n* `100gbase-cr2` - 100GBASE-CR2 (100GE DAC)\n* `100gbase-cr4` - 100GBASE-CR4 (100GE DAC)\n* `100gbase-cr10` - 100GBASE-CR10 (100GE DAC)\n* `100gbase-cwdm4` - 100GBASE-CWDM4 (100GE)\n* `100gbase-dr` - 100GBASE-DR (100GE)\n* `100gbase-er4` - 100GBASE-ER4 (100GE)\n* `100gbase-fr1` - 100GBASE-FR1 (100GE)\n* `100gbase-lr1` - 100GBASE-LR1 (100GE)\n* `100gbase-lr4` - 100GBASE-LR4 (100GE)\n* `100gbase-sr1` - 100GBASE-SR1 (100GE)\n* `100gbase-sr1.2` - 100GBASE-SR1.2 (100GE BiDi)\n* `100gbase-sr2` - 100GBASE-SR2 (100GE)\n* `100gbase-sr4` - 100GBASE-SR4 (100GE)\n* `100gbase-sr10` - 100GBASE-SR10 (100GE)\n* `100gbase-zr` - 100GBASE-ZR (100GE)\n* `200gbase-cr2` - 200GBASE-CR2 (200GE)\n* `200gbase-cr4` - 200GBASE-CR4 (200GE)\n* `200gbase-dr4` - 200GBASE-DR4 (200GE)\n* `200gbase-er4` - 200GBASE-ER4 (200GE)\n* `200gbase-fr4` - 200GBASE-FR4 (200GE)\n* `200gbase-lr4` - 200GBASE-LR4 (200GE)\n* `200gbase-sr2` - 200GBASE-SR2 (200GE)\n* `200gbase-sr4` - 200GBASE-SR4 (200GE)\n* `200gbase-vr2` - 200GBASE-VR2 (200GE)\n* `400gbase-cr4` - 400GBASE-CR4 (400GE)\n* `400gbase-dr4` - 400GBASE-DR4 (400GE)\n* `400gbase-er8` - 400GBASE-ER8 (400GE)\n* `400gbase-fr4` - 400GBASE-FR4 (400GE)\n* `400gbase-fr8` - 400GBASE-FR8 (400GE)\n* `400gbase-lr4` - 400GBASE-LR4 (400GE)\n* `400gbase-lr8` - 400GBASE-LR8 (400GE)\n* `400gbase-sr4` - 400GBASE-SR4 (400GE)\n* `400gbase-sr4_2` - 400GBASE-SR4.2 (400GE BiDi)\n* `400gbase-sr8` - 400GBASE-SR8 (400GE)\n* `400gbase-sr16` - 400GBASE-SR16 (400GE)\n* `400gbase-vr4` - 400GBASE-VR4 (400GE)\n* `400gbase-zr` - 400GBASE-ZR (400GE)\n* `800gbase-cr8` - 800GBASE-CR8 (800GE)\n* `800gbase-dr8` - 800GBASE-DR8 (800GE)\n* `800gbase-sr8` - 800GBASE-SR8 (800GE)\n* `800gbase-vr8` - 800GBASE-VR8 (800GE)\n* `1.6tbase-cr8` - 1.6TBASE-CR8 (1.6TE)\n* `1.6tbase-dr8` - 1.6TBASE-DR8 (1.6TE)\n* `1.6tbase-dr8-2` - 1.6TBASE-DR8-2 (1.6TE)\n* `100base-x-sfp` - SFP (100ME)\n* `1000base-x-gbic` - GBIC (1GE)\n* `1000base-x-sfp` - SFP (1GE)\n* `2.5gbase-x-sfp` - SFP (2.5GE)\n* `10gbase-x-sfpp` - SFP+ (10GE)\n* `10gbase-x-xenpak` - XENPAK (10GE)\n* `10gbase-x-xfp` - XFP (10GE)\n* `10gbase-x-x2` - X2 (10GE)\n* `25gbase-x-sfp28` - SFP28 (25GE)\n* `40gbase-x-qsfpp` - QSFP+ (40GE)\n* `50gbase-x-sfp28` - QSFP28 (50GE)\n* `50gbase-x-sfp56` - SFP56 (50GE)\n* `100gbase-x-cfp` - CFP (100GE)\n* `100gbase-x-cfp2` - CFP2 (100GE)\n* `100gbase-x-cfp4` - CFP4 (100GE)\n* `100gbase-x-cxp` - CXP (100GE)\n* `100gbase-x-cpak` - Cisco CPAK (100GE)\n* `100gbase-x-dsfp` - DSFP (100GE)\n* `100gbase-x-qsfp28` - QSFP28 (100GE)\n* `100gbase-x-qsfpdd` - QSFP-DD (100GE)\n* `100gbase-x-sfp112` - SFP112 (100GE)\n* `100gbase-x-sfpdd` - SFP-DD (100GE)\n* `200gbase-x-cfp2` - CFP2 (200GE)\n* `200gbase-x-qsfp56` - QSFP56 (200GE)\n* `200gbase-x-qsfpdd` - QSFP-DD (200GE)\n* `400gbase-x-qsfp112` - QSFP112 (400GE)\n* `400gbase-x-qsfpdd` - QSFP-DD (400GE)\n* `400gbase-x-cdfp` - CDFP (400GE)\n* `400gbase-x-cfp2` - CFP2 (400GE)\n* `400gbase-x-cfp8` - CPF8 (400GE)\n* `400gbase-x-osfp` - OSFP (400GE)\n* `400gbase-x-osfp-rhs` - OSFP-RHS (400GE)\n* `800gbase-x-osfp` - OSFP (800GE)\n* `800gbase-x-qsfpdd` - QSFP-DD (800GE)\n* `1.6tbase-x-osfp1600` - OSFP1600 (1.6TE)\n* `1.6tbase-x-osfp1600-rhs` - OSFP1600-RHS (1.6TE)\n* `1.6tbase-x-qsfpdd1600` - QSFP-DD1600 (1.6TE)\n* `1000base-kx` - 1000BASE-KX (1GE)\n* `2.5gbase-kx` - 2.5GBASE-KX (2.5GE)\n* `5gbase-kr` - 5GBASE-KR (5GE)\n* `10gbase-kr` - 10GBASE-KR (10GE)\n* `10gbase-kx4` - 10GBASE-KX4 (10GE)\n* `25gbase-kr` - 25GBASE-KR (25GE)\n* `40gbase-kr4` - 40GBASE-KR4 (40GE)\n* `50gbase-kr` - 50GBASE-KR (50GE)\n* `100gbase-kp4` - 100GBASE-KP4 (100GE)\n* `100gbase-kr2` - 100GBASE-KR2 (100GE)\n* `100gbase-kr4` - 100GBASE-KR4 (100GE)\n* `1.6tbase-kr8` - 1.6TBASE-KR8 (1.6TE)\n* `ieee802.11a` - IEEE 802.11a\n* `ieee802.11g` - IEEE 802.11b/g\n* `ieee802.11n` - IEEE 802.11n (Wi-Fi 4)\n* `ieee802.11ac` - IEEE 802.11ac (Wi-Fi 5)\n* `ieee802.11ad` - IEEE 802.11ad (WiGig)\n* `ieee802.11ax` - IEEE 802.11ax (Wi-Fi 6)\n* `ieee802.11ay` - IEEE 802.11ay (WiGig)\n* `ieee802.11be` - IEEE 802.11be (Wi-Fi 7)\n* `ieee802.15.1` - IEEE 802.15.1 (Bluetooth)\n* `ieee802.15.4` - IEEE 802.15.4 (LR-WPAN)\n* `other-wireless` - Other (Wireless)\n* `gsm` - GSM\n* `cdma` - CDMA\n* `lte` - LTE\n* `4g` - 4G\n* `5g` - 5G\n* `sonet-oc3` - OC-3/STM-1\n* `sonet-oc12` - OC-12/STM-4\n* `sonet-oc48` - OC-48/STM-16\n* `sonet-oc192` - OC-192/STM-64\n* `sonet-oc768` - OC-768/STM-256\n* `sonet-oc1920` - OC-1920/STM-640\n* `sonet-oc3840` - OC-3840/STM-1234\n* `1gfc-sfp` - SFP (1GFC)\n* `2gfc-sfp` - SFP (2GFC)\n* `4gfc-sfp` - SFP (4GFC)\n* `8gfc-sfpp` - SFP+ (8GFC)\n* `16gfc-sfpp` - SFP+ (16GFC)\n* `32gfc-sfp28` - SFP28 (32GFC)\n* `32gfc-sfpp` - SFP+ (32GFC)\n* `64gfc-qsfpp` - QSFP+ (64GFC)\n* `64gfc-sfpdd` - SFP-DD (64GFC)\n* `64gfc-sfpp` - SFP+ (64GFC)\n* `128gfc-qsfp28` - QSFP28 (128GFC)\n* `infiniband-sdr` - SDR (2 Gbps)\n* `infiniband-ddr` - DDR (4 Gbps)\n* `infiniband-qdr` - QDR (8 Gbps)\n* `infiniband-fdr10` - FDR10 (10 Gbps)\n* `infiniband-fdr` - FDR (13.5 Gbps)\n* `infiniband-edr` - EDR (25 Gbps)\n* `infiniband-hdr` - HDR (50 Gbps)\n* `infiniband-ndr` - NDR (100 Gbps)\n* `infiniband-xdr` - XDR (200 Gbps)\n* `infiniband-sdr-4x` - SDR 4X (8 Gbps)\n* `infiniband-ddr-4x` - DDR 4X (16 Gbps)\n* `infiniband-qdr-4x` - QDR 4X (32 Gbps)\n* `infiniband-fdr10-4x` - FDR10 4X (40 Gbps)\n* `infiniband-fdr-4x` - FDR 4X (56 Gbps)\n* `infiniband-edr-4x` - EDR 4X (100 Gbps)\n* `infiniband-hdr-4x` - HDR 4X (200 Gbps)\n* `infiniband-ndr-4x` - NDR 4X (400 Gbps)\n* `infiniband-xdr-4x` - XDR 4X (800 Gbps)\n* `t1` - T1 (1.544 Mbps)\n* `e1` - E1 (2.048 Mbps)\n* `t3` - T3 (45 Mbps)\n* `e3` - E3 (34 Mbps)\n* `xdsl` - xDSL\n* `docsis` - DOCSIS\n* `moca` - MoCA\n* `bpon` - BPON (622 Mbps / 155 Mbps)\n* `epon` - EPON (1 Gbps)\n* `10g-epon` - 10G-EPON (10 Gbps)\n* `gpon` - GPON (2.5 Gbps / 1.25 Gbps)\n* `xg-pon` - XG-PON (10 Gbps / 2.5 Gbps)\n* `xgs-pon` - XGS-PON (10 Gbps)\n* `ng-pon2` - NG-PON2 (TWDM-PON) (4x10 Gbps)\n* `25g-pon` - 25G-PON (25 Gbps)\n* `50g-pon` - 50G-PON (50 Gbps)\n* `cisco-stackwise` - Cisco StackWise\n* `cisco-stackwise-plus` - Cisco StackWise Plus\n* `cisco-flexstack` - Cisco FlexStack\n* `cisco-flexstack-plus` - Cisco FlexStack Plus\n* `cisco-stackwise-80` - Cisco StackWise-80\n* `cisco-stackwise-160` - Cisco StackWise-160\n* `cisco-stackwise-320` - Cisco StackWise-320\n* `cisco-stackwise-480` - Cisco StackWise-480\n* `cisco-stackwise-1t` - Cisco StackWise-1T\n* `juniper-vcp` - Juniper VCP\n* `extreme-summitstack` - Extreme SummitStack\n* `extreme-summitstack-128` - Extreme SummitStack-128\n* `extreme-summitstack-256` - Extreme SummitStack-256\n* `extreme-summitstack-512` - Extreme SummitStack-512\n* `hpe-synergy-interconnect-link` - HPE Synergy Interconnect Link\n* `other` - Other", + "x-spec-enum-id": "19cc901fcea417ad" + }, + "channels": { + "type": "integer", + "maximum": 1024, + "minimum": 1, + "nullable": true, + "description": "The number of channels into which this interface is channelized" + }, + "channel_id": { + "type": "integer", + "maximum": 1024, + "minimum": 1, + "nullable": true, + "description": "The channel on the parent interface to which this subinterface is bound" }, "enabled": { "type": "boolean" @@ -287777,6 +322544,11 @@ "minimum": 1, "nullable": true }, + "mac_address": { + "type": "string", + "nullable": true, + "minLength": 1 + }, "primary_mac_address": { "oneOf": [ { @@ -288275,6 +323047,7 @@ "virtual", "bridge", "lag", + "channel", "100base-fx", "100base-lfx", "100base-tx", @@ -288501,8 +323274,22 @@ "other" ], "type": "string", - "description": "* `virtual` - Virtual\n* `bridge` - Bridge\n* `lag` - Link Aggregation Group (LAG)\n* `100base-fx` - 100BASE-FX (10/100ME)\n* `100base-lfx` - 100BASE-LFX (10/100ME)\n* `100base-tx` - 100BASE-TX (10/100ME)\n* `100base-t1` - 100BASE-T1 (10/100ME)\n* `1000base-bx10-d` - 1000BASE-BX10-D (1GE BiDi Down)\n* `1000base-bx10-u` - 1000BASE-BX10-U (1GE BiDi Up)\n* `1000base-cwdm` - 1000BASE-CWDM (1GE)\n* `1000base-cx` - 1000BASE-CX (1GE DAC)\n* `1000base-dwdm` - 1000BASE-DWDM (1GE)\n* `1000base-ex` - 1000BASE-EX (1GE)\n* `1000base-lsx` - 1000BASE-LSX (1GE)\n* `1000base-lx` - 1000BASE-LX (1GE)\n* `1000base-lx10` - 1000BASE-LX10/LH (1GE)\n* `1000base-sx` - 1000BASE-SX (1GE)\n* `1000base-t` - 1000BASE-T (1GE)\n* `1000base-tx` - 1000BASE-TX (1GE)\n* `1000base-zx` - 1000BASE-ZX (1GE)\n* `2.5gbase-t` - 2.5GBASE-T (2.5GE)\n* `5gbase-t` - 5GBASE-T (5GE)\n* `10gbase-br-d` - 10GBASE-BR-D (10GE BiDi Down)\n* `10gbase-br-u` - 10GBASE-BR-U (10GE BiDi Up)\n* `10gbase-cu` - 10GBASE-CU (10GE DAC Passive Twinax)\n* `10gbase-cx4` - 10GBASE-CX4 (10GE DAC)\n* `10gbase-er` - 10GBASE-ER (10GE)\n* `10gbase-lr` - 10GBASE-LR (10GE)\n* `10gbase-lrm` - 10GBASE-LRM (10GE)\n* `10gbase-lx4` - 10GBASE-LX4 (10GE)\n* `10gbase-sr` - 10GBASE-SR (10GE)\n* `10gbase-t` - 10GBASE-T (10GE)\n* `10gbase-zr` - 10GBASE-ZR (10GE)\n* `25gbase-cr` - 25GBASE-CR (25GE DAC)\n* `25gbase-er` - 25GBASE-ER (25GE)\n* `25gbase-lr` - 25GBASE-LR (25GE)\n* `25gbase-sr` - 25GBASE-SR (25GE)\n* `25gbase-t` - 25GBASE-T (25GE)\n* `40gbase-cr4` - 40GBASE-CR4 (40GE DAC)\n* `40gbase-er4` - 40GBASE-ER4 (40GE)\n* `40gbase-fr4` - 40GBASE-FR4 (40GE)\n* `40gbase-lr4` - 40GBASE-LR4 (40GE)\n* `40gbase-sr4` - 40GBASE-SR4 (40GE)\n* `40gbase-sr4-bd` - 40GBASE-SR4 (40GE BiDi)\n* `50gbase-cr` - 50GBASE-CR (50GE DAC)\n* `50gbase-er` - 50GBASE-ER (50GE)\n* `50gbase-fr` - 50GBASE-FR (50GE)\n* `50gbase-lr` - 50GBASE-LR (50GE)\n* `50gbase-sr` - 50GBASE-SR (50GE)\n* `100gbase-cr1` - 100GBASE-CR1 (100GE DAC)\n* `100gbase-cr2` - 100GBASE-CR2 (100GE DAC)\n* `100gbase-cr4` - 100GBASE-CR4 (100GE DAC)\n* `100gbase-cr10` - 100GBASE-CR10 (100GE DAC)\n* `100gbase-cwdm4` - 100GBASE-CWDM4 (100GE)\n* `100gbase-dr` - 100GBASE-DR (100GE)\n* `100gbase-er4` - 100GBASE-ER4 (100GE)\n* `100gbase-fr1` - 100GBASE-FR1 (100GE)\n* `100gbase-lr1` - 100GBASE-LR1 (100GE)\n* `100gbase-lr4` - 100GBASE-LR4 (100GE)\n* `100gbase-sr1` - 100GBASE-SR1 (100GE)\n* `100gbase-sr1.2` - 100GBASE-SR1.2 (100GE BiDi)\n* `100gbase-sr2` - 100GBASE-SR2 (100GE)\n* `100gbase-sr4` - 100GBASE-SR4 (100GE)\n* `100gbase-sr10` - 100GBASE-SR10 (100GE)\n* `100gbase-zr` - 100GBASE-ZR (100GE)\n* `200gbase-cr2` - 200GBASE-CR2 (200GE)\n* `200gbase-cr4` - 200GBASE-CR4 (200GE)\n* `200gbase-dr4` - 200GBASE-DR4 (200GE)\n* `200gbase-er4` - 200GBASE-ER4 (200GE)\n* `200gbase-fr4` - 200GBASE-FR4 (200GE)\n* `200gbase-lr4` - 200GBASE-LR4 (200GE)\n* `200gbase-sr2` - 200GBASE-SR2 (200GE)\n* `200gbase-sr4` - 200GBASE-SR4 (200GE)\n* `200gbase-vr2` - 200GBASE-VR2 (200GE)\n* `400gbase-cr4` - 400GBASE-CR4 (400GE)\n* `400gbase-dr4` - 400GBASE-DR4 (400GE)\n* `400gbase-er8` - 400GBASE-ER8 (400GE)\n* `400gbase-fr4` - 400GBASE-FR4 (400GE)\n* `400gbase-fr8` - 400GBASE-FR8 (400GE)\n* `400gbase-lr4` - 400GBASE-LR4 (400GE)\n* `400gbase-lr8` - 400GBASE-LR8 (400GE)\n* `400gbase-sr4` - 400GBASE-SR4 (400GE)\n* `400gbase-sr4_2` - 400GBASE-SR4.2 (400GE BiDi)\n* `400gbase-sr8` - 400GBASE-SR8 (400GE)\n* `400gbase-sr16` - 400GBASE-SR16 (400GE)\n* `400gbase-vr4` - 400GBASE-VR4 (400GE)\n* `400gbase-zr` - 400GBASE-ZR (400GE)\n* `800gbase-cr8` - 800GBASE-CR8 (800GE)\n* `800gbase-dr8` - 800GBASE-DR8 (800GE)\n* `800gbase-sr8` - 800GBASE-SR8 (800GE)\n* `800gbase-vr8` - 800GBASE-VR8 (800GE)\n* `1.6tbase-cr8` - 1.6TBASE-CR8 (1.6TE)\n* `1.6tbase-dr8` - 1.6TBASE-DR8 (1.6TE)\n* `1.6tbase-dr8-2` - 1.6TBASE-DR8-2 (1.6TE)\n* `100base-x-sfp` - SFP (100ME)\n* `1000base-x-gbic` - GBIC (1GE)\n* `1000base-x-sfp` - SFP (1GE)\n* `2.5gbase-x-sfp` - SFP (2.5GE)\n* `10gbase-x-sfpp` - SFP+ (10GE)\n* `10gbase-x-xenpak` - XENPAK (10GE)\n* `10gbase-x-xfp` - XFP (10GE)\n* `10gbase-x-x2` - X2 (10GE)\n* `25gbase-x-sfp28` - SFP28 (25GE)\n* `40gbase-x-qsfpp` - QSFP+ (40GE)\n* `50gbase-x-sfp28` - QSFP28 (50GE)\n* `50gbase-x-sfp56` - SFP56 (50GE)\n* `100gbase-x-cfp` - CFP (100GE)\n* `100gbase-x-cfp2` - CFP2 (100GE)\n* `100gbase-x-cfp4` - CFP4 (100GE)\n* `100gbase-x-cxp` - CXP (100GE)\n* `100gbase-x-cpak` - Cisco CPAK (100GE)\n* `100gbase-x-dsfp` - DSFP (100GE)\n* `100gbase-x-qsfp28` - QSFP28 (100GE)\n* `100gbase-x-qsfpdd` - QSFP-DD (100GE)\n* `100gbase-x-sfp112` - SFP112 (100GE)\n* `100gbase-x-sfpdd` - SFP-DD (100GE)\n* `200gbase-x-cfp2` - CFP2 (200GE)\n* `200gbase-x-qsfp56` - QSFP56 (200GE)\n* `200gbase-x-qsfpdd` - QSFP-DD (200GE)\n* `400gbase-x-qsfp112` - QSFP112 (400GE)\n* `400gbase-x-qsfpdd` - QSFP-DD (400GE)\n* `400gbase-x-cdfp` - CDFP (400GE)\n* `400gbase-x-cfp2` - CFP2 (400GE)\n* `400gbase-x-cfp8` - CPF8 (400GE)\n* `400gbase-x-osfp` - OSFP (400GE)\n* `400gbase-x-osfp-rhs` - OSFP-RHS (400GE)\n* `800gbase-x-osfp` - OSFP (800GE)\n* `800gbase-x-qsfpdd` - QSFP-DD (800GE)\n* `1.6tbase-x-osfp1600` - OSFP1600 (1.6TE)\n* `1.6tbase-x-osfp1600-rhs` - OSFP1600-RHS (1.6TE)\n* `1.6tbase-x-qsfpdd1600` - QSFP-DD1600 (1.6TE)\n* `1000base-kx` - 1000BASE-KX (1GE)\n* `2.5gbase-kx` - 2.5GBASE-KX (2.5GE)\n* `5gbase-kr` - 5GBASE-KR (5GE)\n* `10gbase-kr` - 10GBASE-KR (10GE)\n* `10gbase-kx4` - 10GBASE-KX4 (10GE)\n* `25gbase-kr` - 25GBASE-KR (25GE)\n* `40gbase-kr4` - 40GBASE-KR4 (40GE)\n* `50gbase-kr` - 50GBASE-KR (50GE)\n* `100gbase-kp4` - 100GBASE-KP4 (100GE)\n* `100gbase-kr2` - 100GBASE-KR2 (100GE)\n* `100gbase-kr4` - 100GBASE-KR4 (100GE)\n* `1.6tbase-kr8` - 1.6TBASE-KR8 (1.6TE)\n* `ieee802.11a` - IEEE 802.11a\n* `ieee802.11g` - IEEE 802.11b/g\n* `ieee802.11n` - IEEE 802.11n (Wi-Fi 4)\n* `ieee802.11ac` - IEEE 802.11ac (Wi-Fi 5)\n* `ieee802.11ad` - IEEE 802.11ad (WiGig)\n* `ieee802.11ax` - IEEE 802.11ax (Wi-Fi 6)\n* `ieee802.11ay` - IEEE 802.11ay (WiGig)\n* `ieee802.11be` - IEEE 802.11be (Wi-Fi 7)\n* `ieee802.15.1` - IEEE 802.15.1 (Bluetooth)\n* `ieee802.15.4` - IEEE 802.15.4 (LR-WPAN)\n* `other-wireless` - Other (Wireless)\n* `gsm` - GSM\n* `cdma` - CDMA\n* `lte` - LTE\n* `4g` - 4G\n* `5g` - 5G\n* `sonet-oc3` - OC-3/STM-1\n* `sonet-oc12` - OC-12/STM-4\n* `sonet-oc48` - OC-48/STM-16\n* `sonet-oc192` - OC-192/STM-64\n* `sonet-oc768` - OC-768/STM-256\n* `sonet-oc1920` - OC-1920/STM-640\n* `sonet-oc3840` - OC-3840/STM-1234\n* `1gfc-sfp` - SFP (1GFC)\n* `2gfc-sfp` - SFP (2GFC)\n* `4gfc-sfp` - SFP (4GFC)\n* `8gfc-sfpp` - SFP+ (8GFC)\n* `16gfc-sfpp` - SFP+ (16GFC)\n* `32gfc-sfp28` - SFP28 (32GFC)\n* `32gfc-sfpp` - SFP+ (32GFC)\n* `64gfc-qsfpp` - QSFP+ (64GFC)\n* `64gfc-sfpdd` - SFP-DD (64GFC)\n* `64gfc-sfpp` - SFP+ (64GFC)\n* `128gfc-qsfp28` - QSFP28 (128GFC)\n* `infiniband-sdr` - SDR (2 Gbps)\n* `infiniband-ddr` - DDR (4 Gbps)\n* `infiniband-qdr` - QDR (8 Gbps)\n* `infiniband-fdr10` - FDR10 (10 Gbps)\n* `infiniband-fdr` - FDR (13.5 Gbps)\n* `infiniband-edr` - EDR (25 Gbps)\n* `infiniband-hdr` - HDR (50 Gbps)\n* `infiniband-ndr` - NDR (100 Gbps)\n* `infiniband-xdr` - XDR (200 Gbps)\n* `infiniband-sdr-4x` - SDR 4X (8 Gbps)\n* `infiniband-ddr-4x` - DDR 4X (16 Gbps)\n* `infiniband-qdr-4x` - QDR 4X (32 Gbps)\n* `infiniband-fdr10-4x` - FDR10 4X (40 Gbps)\n* `infiniband-fdr-4x` - FDR 4X (56 Gbps)\n* `infiniband-edr-4x` - EDR 4X (100 Gbps)\n* `infiniband-hdr-4x` - HDR 4X (200 Gbps)\n* `infiniband-ndr-4x` - NDR 4X (400 Gbps)\n* `infiniband-xdr-4x` - XDR 4X (800 Gbps)\n* `t1` - T1 (1.544 Mbps)\n* `e1` - E1 (2.048 Mbps)\n* `t3` - T3 (45 Mbps)\n* `e3` - E3 (34 Mbps)\n* `xdsl` - xDSL\n* `docsis` - DOCSIS\n* `moca` - MoCA\n* `bpon` - BPON (622 Mbps / 155 Mbps)\n* `epon` - EPON (1 Gbps)\n* `10g-epon` - 10G-EPON (10 Gbps)\n* `gpon` - GPON (2.5 Gbps / 1.25 Gbps)\n* `xg-pon` - XG-PON (10 Gbps / 2.5 Gbps)\n* `xgs-pon` - XGS-PON (10 Gbps)\n* `ng-pon2` - NG-PON2 (TWDM-PON) (4x10 Gbps)\n* `25g-pon` - 25G-PON (25 Gbps)\n* `50g-pon` - 50G-PON (50 Gbps)\n* `cisco-stackwise` - Cisco StackWise\n* `cisco-stackwise-plus` - Cisco StackWise Plus\n* `cisco-flexstack` - Cisco FlexStack\n* `cisco-flexstack-plus` - Cisco FlexStack Plus\n* `cisco-stackwise-80` - Cisco StackWise-80\n* `cisco-stackwise-160` - Cisco StackWise-160\n* `cisco-stackwise-320` - Cisco StackWise-320\n* `cisco-stackwise-480` - Cisco StackWise-480\n* `cisco-stackwise-1t` - Cisco StackWise-1T\n* `juniper-vcp` - Juniper VCP\n* `extreme-summitstack` - Extreme SummitStack\n* `extreme-summitstack-128` - Extreme SummitStack-128\n* `extreme-summitstack-256` - Extreme SummitStack-256\n* `extreme-summitstack-512` - Extreme SummitStack-512\n* `hpe-synergy-interconnect-link` - HPE Synergy Interconnect Link\n* `other` - Other", - "x-spec-enum-id": "b0c97040e5abdff1" + "description": "* `virtual` - Virtual\n* `bridge` - Bridge\n* `lag` - Link Aggregation Group (LAG)\n* `channel` - Channel\n* `100base-fx` - 100BASE-FX (10/100ME)\n* `100base-lfx` - 100BASE-LFX (10/100ME)\n* `100base-tx` - 100BASE-TX (10/100ME)\n* `100base-t1` - 100BASE-T1 (10/100ME)\n* `1000base-bx10-d` - 1000BASE-BX10-D (1GE BiDi Down)\n* `1000base-bx10-u` - 1000BASE-BX10-U (1GE BiDi Up)\n* `1000base-cwdm` - 1000BASE-CWDM (1GE)\n* `1000base-cx` - 1000BASE-CX (1GE DAC)\n* `1000base-dwdm` - 1000BASE-DWDM (1GE)\n* `1000base-ex` - 1000BASE-EX (1GE)\n* `1000base-lsx` - 1000BASE-LSX (1GE)\n* `1000base-lx` - 1000BASE-LX (1GE)\n* `1000base-lx10` - 1000BASE-LX10/LH (1GE)\n* `1000base-sx` - 1000BASE-SX (1GE)\n* `1000base-t` - 1000BASE-T (1GE)\n* `1000base-tx` - 1000BASE-TX (1GE)\n* `1000base-zx` - 1000BASE-ZX (1GE)\n* `2.5gbase-t` - 2.5GBASE-T (2.5GE)\n* `5gbase-t` - 5GBASE-T (5GE)\n* `10gbase-br-d` - 10GBASE-BR-D (10GE BiDi Down)\n* `10gbase-br-u` - 10GBASE-BR-U (10GE BiDi Up)\n* `10gbase-cu` - 10GBASE-CU (10GE DAC Passive Twinax)\n* `10gbase-cx4` - 10GBASE-CX4 (10GE DAC)\n* `10gbase-er` - 10GBASE-ER (10GE)\n* `10gbase-lr` - 10GBASE-LR (10GE)\n* `10gbase-lrm` - 10GBASE-LRM (10GE)\n* `10gbase-lx4` - 10GBASE-LX4 (10GE)\n* `10gbase-sr` - 10GBASE-SR (10GE)\n* `10gbase-t` - 10GBASE-T (10GE)\n* `10gbase-zr` - 10GBASE-ZR (10GE)\n* `25gbase-cr` - 25GBASE-CR (25GE DAC)\n* `25gbase-er` - 25GBASE-ER (25GE)\n* `25gbase-lr` - 25GBASE-LR (25GE)\n* `25gbase-sr` - 25GBASE-SR (25GE)\n* `25gbase-t` - 25GBASE-T (25GE)\n* `40gbase-cr4` - 40GBASE-CR4 (40GE DAC)\n* `40gbase-er4` - 40GBASE-ER4 (40GE)\n* `40gbase-fr4` - 40GBASE-FR4 (40GE)\n* `40gbase-lr4` - 40GBASE-LR4 (40GE)\n* `40gbase-sr4` - 40GBASE-SR4 (40GE)\n* `40gbase-sr4-bd` - 40GBASE-SR4 (40GE BiDi)\n* `50gbase-cr` - 50GBASE-CR (50GE DAC)\n* `50gbase-er` - 50GBASE-ER (50GE)\n* `50gbase-fr` - 50GBASE-FR (50GE)\n* `50gbase-lr` - 50GBASE-LR (50GE)\n* `50gbase-sr` - 50GBASE-SR (50GE)\n* `100gbase-cr1` - 100GBASE-CR1 (100GE DAC)\n* `100gbase-cr2` - 100GBASE-CR2 (100GE DAC)\n* `100gbase-cr4` - 100GBASE-CR4 (100GE DAC)\n* `100gbase-cr10` - 100GBASE-CR10 (100GE DAC)\n* `100gbase-cwdm4` - 100GBASE-CWDM4 (100GE)\n* `100gbase-dr` - 100GBASE-DR (100GE)\n* `100gbase-er4` - 100GBASE-ER4 (100GE)\n* `100gbase-fr1` - 100GBASE-FR1 (100GE)\n* `100gbase-lr1` - 100GBASE-LR1 (100GE)\n* `100gbase-lr4` - 100GBASE-LR4 (100GE)\n* `100gbase-sr1` - 100GBASE-SR1 (100GE)\n* `100gbase-sr1.2` - 100GBASE-SR1.2 (100GE BiDi)\n* `100gbase-sr2` - 100GBASE-SR2 (100GE)\n* `100gbase-sr4` - 100GBASE-SR4 (100GE)\n* `100gbase-sr10` - 100GBASE-SR10 (100GE)\n* `100gbase-zr` - 100GBASE-ZR (100GE)\n* `200gbase-cr2` - 200GBASE-CR2 (200GE)\n* `200gbase-cr4` - 200GBASE-CR4 (200GE)\n* `200gbase-dr4` - 200GBASE-DR4 (200GE)\n* `200gbase-er4` - 200GBASE-ER4 (200GE)\n* `200gbase-fr4` - 200GBASE-FR4 (200GE)\n* `200gbase-lr4` - 200GBASE-LR4 (200GE)\n* `200gbase-sr2` - 200GBASE-SR2 (200GE)\n* `200gbase-sr4` - 200GBASE-SR4 (200GE)\n* `200gbase-vr2` - 200GBASE-VR2 (200GE)\n* `400gbase-cr4` - 400GBASE-CR4 (400GE)\n* `400gbase-dr4` - 400GBASE-DR4 (400GE)\n* `400gbase-er8` - 400GBASE-ER8 (400GE)\n* `400gbase-fr4` - 400GBASE-FR4 (400GE)\n* `400gbase-fr8` - 400GBASE-FR8 (400GE)\n* `400gbase-lr4` - 400GBASE-LR4 (400GE)\n* `400gbase-lr8` - 400GBASE-LR8 (400GE)\n* `400gbase-sr4` - 400GBASE-SR4 (400GE)\n* `400gbase-sr4_2` - 400GBASE-SR4.2 (400GE BiDi)\n* `400gbase-sr8` - 400GBASE-SR8 (400GE)\n* `400gbase-sr16` - 400GBASE-SR16 (400GE)\n* `400gbase-vr4` - 400GBASE-VR4 (400GE)\n* `400gbase-zr` - 400GBASE-ZR (400GE)\n* `800gbase-cr8` - 800GBASE-CR8 (800GE)\n* `800gbase-dr8` - 800GBASE-DR8 (800GE)\n* `800gbase-sr8` - 800GBASE-SR8 (800GE)\n* `800gbase-vr8` - 800GBASE-VR8 (800GE)\n* `1.6tbase-cr8` - 1.6TBASE-CR8 (1.6TE)\n* `1.6tbase-dr8` - 1.6TBASE-DR8 (1.6TE)\n* `1.6tbase-dr8-2` - 1.6TBASE-DR8-2 (1.6TE)\n* `100base-x-sfp` - SFP (100ME)\n* `1000base-x-gbic` - GBIC (1GE)\n* `1000base-x-sfp` - SFP (1GE)\n* `2.5gbase-x-sfp` - SFP (2.5GE)\n* `10gbase-x-sfpp` - SFP+ (10GE)\n* `10gbase-x-xenpak` - XENPAK (10GE)\n* `10gbase-x-xfp` - XFP (10GE)\n* `10gbase-x-x2` - X2 (10GE)\n* `25gbase-x-sfp28` - SFP28 (25GE)\n* `40gbase-x-qsfpp` - QSFP+ (40GE)\n* `50gbase-x-sfp28` - QSFP28 (50GE)\n* `50gbase-x-sfp56` - SFP56 (50GE)\n* `100gbase-x-cfp` - CFP (100GE)\n* `100gbase-x-cfp2` - CFP2 (100GE)\n* `100gbase-x-cfp4` - CFP4 (100GE)\n* `100gbase-x-cxp` - CXP (100GE)\n* `100gbase-x-cpak` - Cisco CPAK (100GE)\n* `100gbase-x-dsfp` - DSFP (100GE)\n* `100gbase-x-qsfp28` - QSFP28 (100GE)\n* `100gbase-x-qsfpdd` - QSFP-DD (100GE)\n* `100gbase-x-sfp112` - SFP112 (100GE)\n* `100gbase-x-sfpdd` - SFP-DD (100GE)\n* `200gbase-x-cfp2` - CFP2 (200GE)\n* `200gbase-x-qsfp56` - QSFP56 (200GE)\n* `200gbase-x-qsfpdd` - QSFP-DD (200GE)\n* `400gbase-x-qsfp112` - QSFP112 (400GE)\n* `400gbase-x-qsfpdd` - QSFP-DD (400GE)\n* `400gbase-x-cdfp` - CDFP (400GE)\n* `400gbase-x-cfp2` - CFP2 (400GE)\n* `400gbase-x-cfp8` - CPF8 (400GE)\n* `400gbase-x-osfp` - OSFP (400GE)\n* `400gbase-x-osfp-rhs` - OSFP-RHS (400GE)\n* `800gbase-x-osfp` - OSFP (800GE)\n* `800gbase-x-qsfpdd` - QSFP-DD (800GE)\n* `1.6tbase-x-osfp1600` - OSFP1600 (1.6TE)\n* `1.6tbase-x-osfp1600-rhs` - OSFP1600-RHS (1.6TE)\n* `1.6tbase-x-qsfpdd1600` - QSFP-DD1600 (1.6TE)\n* `1000base-kx` - 1000BASE-KX (1GE)\n* `2.5gbase-kx` - 2.5GBASE-KX (2.5GE)\n* `5gbase-kr` - 5GBASE-KR (5GE)\n* `10gbase-kr` - 10GBASE-KR (10GE)\n* `10gbase-kx4` - 10GBASE-KX4 (10GE)\n* `25gbase-kr` - 25GBASE-KR (25GE)\n* `40gbase-kr4` - 40GBASE-KR4 (40GE)\n* `50gbase-kr` - 50GBASE-KR (50GE)\n* `100gbase-kp4` - 100GBASE-KP4 (100GE)\n* `100gbase-kr2` - 100GBASE-KR2 (100GE)\n* `100gbase-kr4` - 100GBASE-KR4 (100GE)\n* `1.6tbase-kr8` - 1.6TBASE-KR8 (1.6TE)\n* `ieee802.11a` - IEEE 802.11a\n* `ieee802.11g` - IEEE 802.11b/g\n* `ieee802.11n` - IEEE 802.11n (Wi-Fi 4)\n* `ieee802.11ac` - IEEE 802.11ac (Wi-Fi 5)\n* `ieee802.11ad` - IEEE 802.11ad (WiGig)\n* `ieee802.11ax` - IEEE 802.11ax (Wi-Fi 6)\n* `ieee802.11ay` - IEEE 802.11ay (WiGig)\n* `ieee802.11be` - IEEE 802.11be (Wi-Fi 7)\n* `ieee802.15.1` - IEEE 802.15.1 (Bluetooth)\n* `ieee802.15.4` - IEEE 802.15.4 (LR-WPAN)\n* `other-wireless` - Other (Wireless)\n* `gsm` - GSM\n* `cdma` - CDMA\n* `lte` - LTE\n* `4g` - 4G\n* `5g` - 5G\n* `sonet-oc3` - OC-3/STM-1\n* `sonet-oc12` - OC-12/STM-4\n* `sonet-oc48` - OC-48/STM-16\n* `sonet-oc192` - OC-192/STM-64\n* `sonet-oc768` - OC-768/STM-256\n* `sonet-oc1920` - OC-1920/STM-640\n* `sonet-oc3840` - OC-3840/STM-1234\n* `1gfc-sfp` - SFP (1GFC)\n* `2gfc-sfp` - SFP (2GFC)\n* `4gfc-sfp` - SFP (4GFC)\n* `8gfc-sfpp` - SFP+ (8GFC)\n* `16gfc-sfpp` - SFP+ (16GFC)\n* `32gfc-sfp28` - SFP28 (32GFC)\n* `32gfc-sfpp` - SFP+ (32GFC)\n* `64gfc-qsfpp` - QSFP+ (64GFC)\n* `64gfc-sfpdd` - SFP-DD (64GFC)\n* `64gfc-sfpp` - SFP+ (64GFC)\n* `128gfc-qsfp28` - QSFP28 (128GFC)\n* `infiniband-sdr` - SDR (2 Gbps)\n* `infiniband-ddr` - DDR (4 Gbps)\n* `infiniband-qdr` - QDR (8 Gbps)\n* `infiniband-fdr10` - FDR10 (10 Gbps)\n* `infiniband-fdr` - FDR (13.5 Gbps)\n* `infiniband-edr` - EDR (25 Gbps)\n* `infiniband-hdr` - HDR (50 Gbps)\n* `infiniband-ndr` - NDR (100 Gbps)\n* `infiniband-xdr` - XDR (200 Gbps)\n* `infiniband-sdr-4x` - SDR 4X (8 Gbps)\n* `infiniband-ddr-4x` - DDR 4X (16 Gbps)\n* `infiniband-qdr-4x` - QDR 4X (32 Gbps)\n* `infiniband-fdr10-4x` - FDR10 4X (40 Gbps)\n* `infiniband-fdr-4x` - FDR 4X (56 Gbps)\n* `infiniband-edr-4x` - EDR 4X (100 Gbps)\n* `infiniband-hdr-4x` - HDR 4X (200 Gbps)\n* `infiniband-ndr-4x` - NDR 4X (400 Gbps)\n* `infiniband-xdr-4x` - XDR 4X (800 Gbps)\n* `t1` - T1 (1.544 Mbps)\n* `e1` - E1 (2.048 Mbps)\n* `t3` - T3 (45 Mbps)\n* `e3` - E3 (34 Mbps)\n* `xdsl` - xDSL\n* `docsis` - DOCSIS\n* `moca` - MoCA\n* `bpon` - BPON (622 Mbps / 155 Mbps)\n* `epon` - EPON (1 Gbps)\n* `10g-epon` - 10G-EPON (10 Gbps)\n* `gpon` - GPON (2.5 Gbps / 1.25 Gbps)\n* `xg-pon` - XG-PON (10 Gbps / 2.5 Gbps)\n* `xgs-pon` - XGS-PON (10 Gbps)\n* `ng-pon2` - NG-PON2 (TWDM-PON) (4x10 Gbps)\n* `25g-pon` - 25G-PON (25 Gbps)\n* `50g-pon` - 50G-PON (50 Gbps)\n* `cisco-stackwise` - Cisco StackWise\n* `cisco-stackwise-plus` - Cisco StackWise Plus\n* `cisco-flexstack` - Cisco FlexStack\n* `cisco-flexstack-plus` - Cisco FlexStack Plus\n* `cisco-stackwise-80` - Cisco StackWise-80\n* `cisco-stackwise-160` - Cisco StackWise-160\n* `cisco-stackwise-320` - Cisco StackWise-320\n* `cisco-stackwise-480` - Cisco StackWise-480\n* `cisco-stackwise-1t` - Cisco StackWise-1T\n* `juniper-vcp` - Juniper VCP\n* `extreme-summitstack` - Extreme SummitStack\n* `extreme-summitstack-128` - Extreme SummitStack-128\n* `extreme-summitstack-256` - Extreme SummitStack-256\n* `extreme-summitstack-512` - Extreme SummitStack-512\n* `hpe-synergy-interconnect-link` - HPE Synergy Interconnect Link\n* `other` - Other", + "x-spec-enum-id": "19cc901fcea417ad" + }, + "channels": { + "type": "integer", + "maximum": 1024, + "minimum": 1, + "nullable": true, + "description": "The number of channels into which this interface is channelized" + }, + "channel_id": { + "type": "integer", + "maximum": 1024, + "minimum": 1, + "nullable": true, + "description": "The channel on the parent interface to which this subinterface is bound" }, "enabled": { "type": "boolean" @@ -288515,6 +323302,14 @@ "type": "string", "maxLength": 200 }, + "parent": { + "allOf": [ + { + "$ref": "#/components/schemas/NestedInterfaceTemplateRequest" + } + ], + "nullable": true + }, "bridge": { "allOf": [ { @@ -289354,6 +324149,12 @@ "type": "string", "maxLength": 200 }, + "module_bay_types": { + "type": "array", + "items": { + "type": "integer" + } + }, "installed_module": { "oneOf": [ { @@ -289462,6 +324263,97 @@ "description": { "type": "string", "maxLength": 200 + }, + "module_bay_types": { + "type": "array", + "items": { + "type": "integer" + } + } + }, + "required": [ + "id" + ] + }, + "PatchedBulkModuleBayTypeRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "manufacturer": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefManufacturerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "color": { + "oneOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{6}$", + "maxLength": 6 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "comments": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} } }, "required": [ @@ -289684,6 +324576,20 @@ "x-spec-enum-id": "5ad4e700c656b09d", "nullable": true }, + "cooling_method": { + "enum": [ + "air", + "liquid", + "hybrid", + "immersion", + "", + null + ], + "type": "string", + "description": "* `air` - Air\n* `liquid` - Liquid\n* `hybrid` - Hybrid\n* `immersion` - Immersion", + "x-spec-enum-id": "fff5375d415a4fdc", + "nullable": true + }, "weight": { "type": "number", "format": "double", @@ -289707,6 +324613,12 @@ "x-spec-enum-id": "2235ce3f404afbc0", "nullable": true }, + "end_of_life": { + "type": "string", + "format": "date", + "nullable": true, + "description": "The date after which this module type is no longer supported by the manufacturer" + }, "description": { "type": "string", "maxLength": 200 @@ -289714,6 +324626,12 @@ "attributes": { "nullable": true }, + "module_bay_types": { + "type": "array", + "items": { + "type": "integer" + } + }, "owner": { "oneOf": [ { @@ -291848,6 +326766,28 @@ "description": "* `front-to-rear` - Front to rear\n* `rear-to-front` - Rear to front", "x-spec-enum-id": "a784734d07ef1b3c" }, + "cooling_capability": { + "enum": [ + "air-only", + "hybrid", + "liquid-only", + "", + null + ], + "type": "string", + "description": "* `air-only` - Air only\n* `hybrid` - Hybrid\n* `liquid-only` - Liquid only", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + }, + "cooling_capacity": { + "type": "number", + "format": "double", + "maximum": 100000000, + "minimum": 0, + "exclusiveMaximum": true, + "nullable": true, + "description": "Cooling capacity (kW)" + }, "description": { "type": "string", "maxLength": 200 @@ -292196,6 +327136,28 @@ "nullable": true, "description": "Maximum depth of a mounted device, in millimeters. For four-post racks, this is the distance between the front and rear rails." }, + "cooling_capability": { + "enum": [ + "air-only", + "hybrid", + "liquid-only", + "", + null + ], + "type": "string", + "description": "* `air-only` - Air only\n* `hybrid` - Hybrid\n* `liquid-only` - Liquid only", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + }, + "cooling_capacity": { + "type": "number", + "format": "double", + "maximum": 100000000, + "minimum": 0, + "exclusiveMaximum": true, + "nullable": true, + "description": "Cooling capacity (kW)" + }, "owner": { "oneOf": [ { @@ -292818,7 +327780,7 @@ }, "PatchedBulkServiceRequest": { "type": "object", - "description": "Base serializer class for models inheriting from PrimaryModel.", + "description": "Shared port-mapping handling for the Service and ServiceTemplate serializers, including backward\ncompatibility for the legacy single-protocol ``protocol``/``ports`` representation.\n\nRead: alongside the ``port_mappings`` list, a service that uses a single protocol also reports the\nlegacy ``protocol`` and ``ports`` fields; a multi-protocol service reports ``null`` for both (it\ncannot be expressed in the old single-protocol format).\n\nWrite: either format is accepted. When the legacy ``protocol``/``ports`` pair is supplied (and\n``port_mappings`` is not), it is translated into ``port_mappings``. Supplying both together is\naccepted only when the legacy fields agree with what ``port_mappings`` implies (e.g. a full-object\nround-trip that echoes back the read representation); a genuine conflict is rejected as ambiguous.\n\nSubclassing ``serializers.Serializer`` (rather than a plain mixin) lets DRF's metaclass collect the\nfields declared here into the inheriting serializers.", "properties": { "id": { "type": "integer" @@ -292837,15 +327799,24 @@ "minLength": 1, "maxLength": 100 }, + "port_mappings": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "protocol": { "enum": [ "tcp", "udp", - "sctp" + "sctp", + null ], "type": "string", - "description": "* `tcp` - TCP\n* `udp` - UDP\n* `sctp` - SCTP", - "x-spec-enum-id": "e4b15bec749a2a32" + "description": "Deprecated; use port_mappings. Reported only for single-protocol services.", + "x-spec-enum-id": "e4b15bec749a2a32", + "nullable": true }, "ports": { "type": "array", @@ -292854,7 +327825,8 @@ "maximum": 65535, "minimum": 1 }, - "title": "Port numbers" + "nullable": true, + "description": "Deprecated; use port_mappings. Reported only for single-protocol services." }, "ipaddresses": { "type": "array", @@ -292902,7 +327874,7 @@ }, "PatchedBulkServiceTemplateRequest": { "type": "object", - "description": "Base serializer class for models inheriting from PrimaryModel.", + "description": "Shared port-mapping handling for the Service and ServiceTemplate serializers, including backward\ncompatibility for the legacy single-protocol ``protocol``/``ports`` representation.\n\nRead: alongside the ``port_mappings`` list, a service that uses a single protocol also reports the\nlegacy ``protocol`` and ``ports`` fields; a multi-protocol service reports ``null`` for both (it\ncannot be expressed in the old single-protocol format).\n\nWrite: either format is accepted. When the legacy ``protocol``/``ports`` pair is supplied (and\n``port_mappings`` is not), it is translated into ``port_mappings``. Supplying both together is\naccepted only when the legacy fields agree with what ``port_mappings`` implies (e.g. a full-object\nround-trip that echoes back the read representation); a genuine conflict is rejected as ambiguous.\n\nSubclassing ``serializers.Serializer`` (rather than a plain mixin) lets DRF's metaclass collect the\nfields declared here into the inheriting serializers.", "properties": { "id": { "type": "integer" @@ -292912,15 +327884,24 @@ "minLength": 1, "maxLength": 100 }, + "port_mappings": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "protocol": { "enum": [ "tcp", "udp", - "sctp" + "sctp", + null ], "type": "string", - "description": "* `tcp` - TCP\n* `udp` - UDP\n* `sctp` - SCTP", - "x-spec-enum-id": "e4b15bec749a2a32" + "description": "Deprecated; use port_mappings. Reported only for single-protocol services.", + "x-spec-enum-id": "e4b15bec749a2a32", + "nullable": true }, "ports": { "type": "array", @@ -292929,7 +327910,8 @@ "maximum": 65535, "minimum": 1 }, - "title": "Port numbers" + "nullable": true, + "description": "Deprecated; use port_mappings. Reported only for single-protocol services." }, "description": { "type": "string", @@ -293520,10 +328502,6 @@ "minimum": 0, "nullable": true, "description": "ID of the cryptographic pepper used to hash the token (v2 only)" - }, - "token": { - "type": "string", - "minLength": 1 } }, "required": [ @@ -294168,7 +329146,7 @@ }, "PatchedBulkVMInterfaceRequest": { "type": "object", - "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", + "description": "Mixin for Interface and VMInterface serializers that adds a `mac_address` shortcut field for\ncreating/updating the primary MACAddress in a single request. The validated write is centralized\non the interface model (BaseInterface.set_primary_mac_address); this mixin is a thin adapter that\nowns the permission check, the shortcut-vs-primary_mac_address conflict guard, and translating the\nmodel's validation errors into API errors.", "properties": { "id": { "type": "integer" @@ -294213,6 +329191,11 @@ "minimum": 1, "nullable": true }, + "mac_address": { + "type": "string", + "nullable": true, + "minLength": 1 + }, "primary_mac_address": { "oneOf": [ { @@ -294926,93 +329909,7 @@ "id" ] }, - "PatchedBulkVirtualMachineTypeRequest": { - "type": "object", - "description": "Base serializer class for models inheriting from PrimaryModel.", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100 - }, - "slug": { - "type": "string", - "minLength": 1, - "maxLength": 100, - "pattern": "^[-a-zA-Z0-9_]+$" - }, - "default_platform": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefPlatformRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "default_vcpus": { - "type": "number", - "format": "double", - "maximum": 10000, - "minimum": 0.01, - "exclusiveMaximum": true, - "nullable": true - }, - "default_memory": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0, - "nullable": true - }, - "description": { - "type": "string", - "maxLength": 200 - }, - "owner": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefOwnerRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "comments": { - "type": "string" - }, - "tags": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NestedTagRequest" - } - }, - "custom_fields": { - "type": "object", - "additionalProperties": {} - } - }, - "required": [ - "id" - ] - }, - "PatchedBulkVirtualMachineWithConfigContextRequest": { + "PatchedBulkVirtualMachineRequest": { "type": "object", "description": "Base serializer class for models inheriting from PrimaryModel.", "properties": { @@ -295275,6 +330172,92 @@ "id" ] }, + "PatchedBulkVirtualMachineTypeRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "default_platform": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefPlatformRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "default_vcpus": { + "type": "number", + "format": "double", + "maximum": 10000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "default_memory": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0, + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "comments": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "id" + ] + }, "PatchedBulkWebhookRequest": { "type": "object", "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", @@ -295322,7 +330305,7 @@ }, "body_template": { "type": "string", - "description": "Jinja2 template for a custom request body. If blank, a JSON object representing the change will be included. Available context data includes: event, model, timestamp, username, request_id, and data." + "description": "Jinja2 template for a custom request body. If blank, a JSON object representing the change will be included. Available context data includes: event, model, timestamp, request, and data." }, "secret": { "type": "string", @@ -295339,6 +330322,13 @@ "description": "The specific CA certificate file to use for SSL verification. Leave blank to use the system defaults.", "maxLength": 4096 }, + "timeout": { + "type": "integer", + "maximum": 3600, + "minimum": 1, + "nullable": true, + "description": "The maximum time (in seconds) to wait for a response before failing the request. Leave blank to use the system default (60 seconds)." + }, "custom_fields": { "type": "object", "additionalProperties": {} @@ -297262,6 +332252,12 @@ "type": "string", "maxLength": 200 }, + "module_bay_types": { + "type": "array", + "items": { + "type": "integer" + } + }, "installed_module": { "oneOf": [ { @@ -297364,6 +332360,91 @@ "description": { "type": "string", "maxLength": 200 + }, + "module_bay_types": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "PatchedModuleBayTypeRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "manufacturer": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefManufacturerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "color": { + "oneOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{6}$", + "maxLength": 6 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "comments": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} } } }, @@ -298428,10 +333509,6 @@ "minimum": 0, "nullable": true, "description": "ID of the cryptographic pepper used to hash the token (v2 only)" - }, - "token": { - "type": "string", - "minLength": 1 } } }, @@ -299020,7 +334097,7 @@ }, "body_template": { "type": "string", - "description": "Jinja2 template for a custom request body. If blank, a JSON object representing the change will be included. Available context data includes: event, model, timestamp, username, request_id, and data." + "description": "Jinja2 template for a custom request body. If blank, a JSON object representing the change will be included. Available context data includes: event, model, timestamp, request, and data." }, "secret": { "type": "string", @@ -299037,6 +334114,13 @@ "description": "The specific CA certificate file to use for SSL verification. Leave blank to use the system defaults.", "maxLength": 4096 }, + "timeout": { + "type": "integer", + "maximum": 3600, + "minimum": 1, + "nullable": true, + "description": "The maximum time (in seconds) to wait for a response before failing the request. Leave blank to use the system default (60 seconds)." + }, "custom_fields": { "type": "object", "additionalProperties": {} @@ -300177,6 +335261,730 @@ } } }, + "PatchedWritableCoolingFeedRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "cooling_source": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefCoolingSourceRequest" + } + ] + }, + "rack": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefRackRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "status": { + "enum": [ + "offline", + "active", + "planned", + "failed" + ], + "type": "string", + "description": "* `offline` - Offline\n* `active` - Active\n* `planned` - Planned\n* `failed` - Failed", + "x-spec-enum-id": "ec530572dc778583" + }, + "cooling_capacity": { + "type": "number", + "format": "double", + "maximum": 100000000, + "minimum": 0, + "exclusiveMaximum": true, + "nullable": true, + "description": "Rated cooling capacity (kW)" + }, + "max_flow": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "max_flow_unit": { + "enum": [ + "lpm", + "m3ph", + "gpm", + "", + null + ], + "type": "string", + "description": "* `lpm` - Liters per minute (L/min)\n* `m3ph` - Cubic meters per hour (m³/h)\n* `gpm` - Gallons per minute (GPM)", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "tenant": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefTenantRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "comments": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + } + }, + "PatchedWritableCoolingIntakeRequest": { + "type": "object", + "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", + "properties": { + "device": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefDeviceRequest" + } + ] + }, + "module": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefModuleRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "label": { + "type": "string", + "description": "Physical label", + "maxLength": 64 + }, + "type": { + "enum": [ + "uqd", + "uqdb", + "qdc", + "camlock", + "npt", + "bsp", + "proprietary", + "", + null + ], + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true, + "description": "Physical connector type\n\n* `uqd` - UQD (Universal Quick Disconnect)\n* `uqdb` - UQDB (Universal Quick Disconnect, Blind-mate)\n* `qdc` - QDC (Quick Disconnect Coupling)\n* `camlock` - Camlock (cam-and-groove)\n* `npt` - NPT (threaded)\n* `bsp` - BSP (threaded)\n* `proprietary` - Proprietary" + }, + "diameter": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "diameter_unit": { + "enum": [ + "mm", + "cm", + "in", + "", + null + ], + "type": "string", + "description": "* `mm` - Millimeters\n* `cm` - Centimeters\n* `in` - Inches", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + }, + "max_flow": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "max_flow_unit": { + "enum": [ + "lpm", + "m3ph", + "gpm", + "", + null + ], + "type": "string", + "description": "* `lpm` - Liters per minute (L/min)\n* `m3ph` - Cubic meters per hour (m³/h)\n* `gpm` - Gallons per minute (GPM)", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + }, + "cooling_outflow": { + "type": "integer", + "nullable": true, + "description": "The upstream cooling outflow supplying this intake" + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + } + }, + "PatchedWritableCoolingIntakeTemplateRequest": { + "type": "object", + "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)", + "properties": { + "device_type": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefDeviceTypeRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "module_type": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefModuleTypeRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "description": "{module} is accepted as a substitution for the module bay position when attached to a module type.", + "maxLength": 64 + }, + "label": { + "type": "string", + "description": "Physical label", + "maxLength": 64 + }, + "type": { + "enum": [ + "uqd", + "uqdb", + "qdc", + "camlock", + "npt", + "bsp", + "proprietary", + "", + null + ], + "type": "string", + "description": "* `uqd` - UQD (Universal Quick Disconnect)\n* `uqdb` - UQDB (Universal Quick Disconnect, Blind-mate)\n* `qdc` - QDC (Quick Disconnect Coupling)\n* `camlock` - Camlock (cam-and-groove)\n* `npt` - NPT (threaded)\n* `bsp` - BSP (threaded)\n* `proprietary` - Proprietary", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + }, + "diameter": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "diameter_unit": { + "enum": [ + "mm", + "cm", + "in", + "", + null + ], + "type": "string", + "description": "* `mm` - Millimeters\n* `cm` - Centimeters\n* `in` - Inches", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + }, + "max_flow": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "max_flow_unit": { + "enum": [ + "lpm", + "m3ph", + "gpm", + "", + null + ], + "type": "string", + "description": "* `lpm` - Liters per minute (L/min)\n* `m3ph` - Cubic meters per hour (m³/h)\n* `gpm` - Gallons per minute (GPM)", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + } + } + }, + "PatchedWritableCoolingOutflowRequest": { + "type": "object", + "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", + "properties": { + "device": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefDeviceRequest" + } + ] + }, + "module": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefModuleRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "label": { + "type": "string", + "description": "Physical label", + "maxLength": 64 + }, + "type": { + "enum": [ + "uqd", + "uqdb", + "qdc", + "camlock", + "npt", + "bsp", + "proprietary", + "", + null + ], + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true, + "description": "Physical connector type\n\n* `uqd` - UQD (Universal Quick Disconnect)\n* `uqdb` - UQDB (Universal Quick Disconnect, Blind-mate)\n* `qdc` - QDC (Quick Disconnect Coupling)\n* `camlock` - Camlock (cam-and-groove)\n* `npt` - NPT (threaded)\n* `bsp` - BSP (threaded)\n* `proprietary` - Proprietary" + }, + "diameter": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "diameter_unit": { + "enum": [ + "mm", + "cm", + "in", + "", + null + ], + "type": "string", + "description": "* `mm` - Millimeters\n* `cm` - Centimeters\n* `in` - Inches", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + }, + "cooling_intake": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefCoolingIntakeRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + } + }, + "PatchedWritableCoolingOutflowTemplateRequest": { + "type": "object", + "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)", + "properties": { + "device_type": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefDeviceTypeRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "module_type": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefModuleTypeRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "description": "{module} is accepted as a substitution for the module bay position when attached to a module type.", + "maxLength": 64 + }, + "label": { + "type": "string", + "description": "Physical label", + "maxLength": 64 + }, + "type": { + "enum": [ + "uqd", + "uqdb", + "qdc", + "camlock", + "npt", + "bsp", + "proprietary", + "", + null + ], + "type": "string", + "description": "* `uqd` - UQD (Universal Quick Disconnect)\n* `uqdb` - UQDB (Universal Quick Disconnect, Blind-mate)\n* `qdc` - QDC (Quick Disconnect Coupling)\n* `camlock` - Camlock (cam-and-groove)\n* `npt` - NPT (threaded)\n* `bsp` - BSP (threaded)\n* `proprietary` - Proprietary", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + }, + "diameter": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "diameter_unit": { + "enum": [ + "mm", + "cm", + "in", + "", + null + ], + "type": "string", + "description": "* `mm` - Millimeters\n* `cm` - Centimeters\n* `in` - Inches", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + }, + "cooling_intake": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefCoolingIntakeTemplateRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + } + } + }, + "PatchedWritableCoolingSourceRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "site": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefSiteRequest" + } + ] + }, + "location": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefLocationRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "type": { + "enum": [ + "chiller", + "cooling-tower", + "dry-cooler", + "crac", + "crah" + ], + "type": "string", + "description": "* `chiller` - Chiller\n* `cooling-tower` - Cooling tower\n* `dry-cooler` - Dry cooler\n* `crac` - CRAC\n* `crah` - CRAH", + "x-spec-enum-id": "f225f830b0d77ac5" + }, + "status": { + "enum": [ + "offline", + "active", + "planned", + "failed" + ], + "type": "string", + "description": "* `offline` - Offline\n* `active` - Active\n* `planned` - Planned\n* `failed` - Failed", + "x-spec-enum-id": "ec530572dc778583" + }, + "fluid_type": { + "enum": [ + "water", + "water-glycol", + "dielectric", + "refrigerant", + "", + null + ], + "type": "string", + "description": "* `water` - Water\n* `water-glycol` - Water/glycol\n* `dielectric` - Dielectric\n* `refrigerant` - Refrigerant", + "x-spec-enum-id": "b558e2a3f7349765", + "nullable": true + }, + "cooling_capacity": { + "type": "number", + "format": "double", + "maximum": 100000000, + "minimum": 0, + "exclusiveMaximum": true, + "nullable": true, + "description": "Total rated cooling capacity (kW)" + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "comments": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + } + }, "PatchedWritableCustomFieldChoiceSetRequest": { "type": "object", "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", @@ -300284,8 +336092,8 @@ "multiobject" ], "type": "string", - "x-spec-enum-id": "47c52a3d983e924c", - "description": "The type of data this custom field holds\n\n* `text` - Text\n* `longtext` - Text (long)\n* `integer` - Integer\n* `decimal` - Decimal\n* `boolean` - Boolean (true/false)\n* `date` - Date\n* `datetime` - Date & time\n* `url` - URL\n* `json` - JSON\n* `select` - Selection\n* `multiselect` - Multiple selection\n* `object` - Object\n* `multiobject` - Multiple objects" + "x-spec-enum-id": "6ec6eff91d34cc44", + "description": "The type of data this custom field holds\n\n* `text` - Text\n* `longtext` - Text (long)\n* `integer` - Integer\n* `decimal` - Decimal\n* `boolean` - Boolean\n* `date` - Date\n* `datetime` - Date & time\n* `url` - URL\n* `json` - JSON\n* `select` - Selection\n* `multiselect` - Multiple selection\n* `object` - Object\n* `multiobject` - Multiple objects" }, "related_object_type": { "type": "string", @@ -300361,6 +336169,10 @@ "type": "boolean", "description": "Replicate this value when cloning objects" }, + "nulls_first": { + "type": "boolean", + "description": "Sort null values before non-null values when ordering by this field" + }, "default": { "nullable": true, "description": "Default value for the field (must be a JSON value). Encapsulate strings with double quotes (e.g. \"Foo\")." @@ -300520,251 +336332,7 @@ } } }, - "PatchedWritableDeviceRoleRequest": { - "type": "object", - "description": "Base serializer class for models inheriting from NestedGroupModel.", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100 - }, - "slug": { - "type": "string", - "minLength": 1, - "maxLength": 100, - "pattern": "^[-a-zA-Z0-9_]+$" - }, - "color": { - "type": "string", - "minLength": 1, - "pattern": "^[0-9a-f]{6}$", - "maxLength": 6 - }, - "vm_role": { - "type": "boolean", - "description": "Virtual machines may be assigned to this role" - }, - "config_template": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefConfigTemplateRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "parent": { - "type": "integer", - "nullable": true - }, - "description": { - "type": "string", - "maxLength": 200 - }, - "tags": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NestedTagRequest" - } - }, - "custom_fields": { - "type": "object", - "additionalProperties": {} - }, - "owner": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefOwnerRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "comments": { - "type": "string" - } - } - }, - "PatchedWritableDeviceTypeRequest": { - "type": "object", - "description": "Base serializer class for models inheriting from PrimaryModel.", - "properties": { - "manufacturer": { - "oneOf": [ - { - "type": "integer" - }, - { - "$ref": "#/components/schemas/BriefManufacturerRequest" - } - ] - }, - "default_platform": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefPlatformRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "model": { - "type": "string", - "minLength": 1, - "maxLength": 100 - }, - "slug": { - "type": "string", - "minLength": 1, - "maxLength": 100, - "pattern": "^[-a-zA-Z0-9_]+$" - }, - "part_number": { - "type": "string", - "description": "Discrete part number (optional)", - "maxLength": 50 - }, - "u_height": { - "type": "number", - "format": "double", - "maximum": 1000, - "minimum": 0.0, - "exclusiveMaximum": true, - "default": 1.0, - "title": "Position (U)" - }, - "exclude_from_utilization": { - "type": "boolean", - "description": "Devices of this type are excluded when calculating rack utilization." - }, - "is_full_depth": { - "type": "boolean", - "description": "Device consumes both front and rear rack faces." - }, - "subdevice_role": { - "enum": [ - "parent", - "child", - "", - null - ], - "type": "string", - "x-spec-enum-id": "65a61d5e1deb4a24", - "nullable": true, - "title": "Parent/child status", - "description": "Parent devices house child devices in device bays. Leave blank if this device type is neither a parent nor a child.\n\n* `parent` - Parent\n* `child` - Child" - }, - "airflow": { - "enum": [ - "front-to-rear", - "rear-to-front", - "left-to-right", - "right-to-left", - "side-to-rear", - "rear-to-side", - "bottom-to-top", - "top-to-bottom", - "passive", - "mixed", - "", - null - ], - "type": "string", - "description": "* `front-to-rear` - Front to rear\n* `rear-to-front` - Rear to front\n* `left-to-right` - Left to right\n* `right-to-left` - Right to left\n* `side-to-rear` - Side to rear\n* `rear-to-side` - Rear to side\n* `bottom-to-top` - Bottom to top\n* `top-to-bottom` - Top to bottom\n* `passive` - Passive\n* `mixed` - Mixed", - "x-spec-enum-id": "11cb3d363b41ba9e", - "nullable": true - }, - "weight": { - "type": "number", - "format": "double", - "maximum": 1000000, - "minimum": -1000000, - "exclusiveMaximum": true, - "exclusiveMinimum": true, - "nullable": true - }, - "weight_unit": { - "enum": [ - "kg", - "g", - "lb", - "oz", - "", - null - ], - "type": "string", - "description": "* `kg` - Kilograms\n* `g` - Grams\n* `lb` - Pounds\n* `oz` - Ounces", - "x-spec-enum-id": "2235ce3f404afbc0", - "nullable": true - }, - "front_image": { - "type": "string", - "format": "binary", - "nullable": true - }, - "rear_image": { - "type": "string", - "format": "binary", - "nullable": true - }, - "description": { - "type": "string", - "maxLength": 200 - }, - "owner": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefOwnerRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "comments": { - "type": "string" - }, - "tags": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NestedTagRequest" - } - }, - "custom_fields": { - "type": "object", - "additionalProperties": {} - } - } - }, - "PatchedWritableDeviceWithConfigContextRequest": { + "PatchedWritableDeviceRequest": { "type": "object", "description": "Base serializer class for models inheriting from PrimaryModel.", "properties": { @@ -300951,6 +336519,20 @@ "x-spec-enum-id": "11cb3d363b41ba9e", "nullable": true }, + "cooling_method": { + "enum": [ + "air", + "liquid", + "hybrid", + "immersion", + "", + null + ], + "type": "string", + "description": "* `air` - Air\n* `liquid` - Liquid\n* `hybrid` - Hybrid\n* `immersion` - Immersion", + "x-spec-enum-id": "fff5375d415a4fdc", + "nullable": true + }, "primary_ip4": { "oneOf": [ { @@ -301099,6 +336681,270 @@ } } }, + "PatchedWritableDeviceRoleRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from NestedGroupModel.", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "color": { + "type": "string", + "minLength": 1, + "pattern": "^[0-9a-f]{6}$", + "maxLength": 6 + }, + "vm_role": { + "type": "boolean", + "description": "Virtual machines may be assigned to this role" + }, + "config_template": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefConfigTemplateRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "parent": { + "type": "integer", + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "comments": { + "type": "string" + } + } + }, + "PatchedWritableDeviceTypeRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "manufacturer": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefManufacturerRequest" + } + ] + }, + "default_platform": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefPlatformRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "part_number": { + "type": "string", + "description": "Discrete part number (optional)", + "maxLength": 50 + }, + "u_height": { + "type": "number", + "format": "double", + "maximum": 1000, + "minimum": 0.0, + "exclusiveMaximum": true, + "default": 1.0, + "title": "Position (U)" + }, + "exclude_from_utilization": { + "type": "boolean", + "description": "Devices of this type are excluded when calculating rack utilization." + }, + "is_full_depth": { + "type": "boolean", + "description": "Device consumes both front and rear rack faces." + }, + "subdevice_role": { + "enum": [ + "parent", + "child", + "", + null + ], + "type": "string", + "x-spec-enum-id": "65a61d5e1deb4a24", + "nullable": true, + "title": "Parent/child status", + "description": "Parent devices house child devices in device bays. Leave blank if this device type is neither a parent nor a child.\n\n* `parent` - Parent\n* `child` - Child" + }, + "airflow": { + "enum": [ + "front-to-rear", + "rear-to-front", + "left-to-right", + "right-to-left", + "side-to-rear", + "rear-to-side", + "bottom-to-top", + "top-to-bottom", + "passive", + "mixed", + "", + null + ], + "type": "string", + "description": "* `front-to-rear` - Front to rear\n* `rear-to-front` - Rear to front\n* `left-to-right` - Left to right\n* `right-to-left` - Right to left\n* `side-to-rear` - Side to rear\n* `rear-to-side` - Rear to side\n* `bottom-to-top` - Bottom to top\n* `top-to-bottom` - Top to bottom\n* `passive` - Passive\n* `mixed` - Mixed", + "x-spec-enum-id": "11cb3d363b41ba9e", + "nullable": true + }, + "cooling_method": { + "enum": [ + "air", + "liquid", + "hybrid", + "immersion", + "", + null + ], + "type": "string", + "description": "* `air` - Air\n* `liquid` - Liquid\n* `hybrid` - Hybrid\n* `immersion` - Immersion", + "x-spec-enum-id": "fff5375d415a4fdc", + "nullable": true + }, + "weight": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": -1000000, + "exclusiveMaximum": true, + "exclusiveMinimum": true, + "nullable": true + }, + "weight_unit": { + "enum": [ + "kg", + "g", + "lb", + "oz", + "", + null + ], + "type": "string", + "description": "* `kg` - Kilograms\n* `g` - Grams\n* `lb` - Pounds\n* `oz` - Ounces", + "x-spec-enum-id": "2235ce3f404afbc0", + "nullable": true + }, + "end_of_life": { + "type": "string", + "format": "date", + "nullable": true, + "description": "The date after which this device type is no longer supported by the manufacturer" + }, + "front_image": { + "type": "string", + "format": "binary", + "nullable": true + }, + "rear_image": { + "type": "string", + "format": "binary", + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "comments": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + } + }, "PatchedWritableEventRuleRequest": { "type": "object", "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", @@ -301150,7 +336996,8 @@ "x-spec-enum-id": "287901b937995956" }, "action_object_type": { - "type": "string" + "type": "string", + "nullable": true }, "action_object_id": { "type": "integer", @@ -302214,7 +338061,7 @@ }, "PatchedWritableInterfaceRequest": { "type": "object", - "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", + "description": "Mixin for Interface and VMInterface serializers that adds a `mac_address` shortcut field for\ncreating/updating the primary MACAddress in a single request. The validated write is centralized\non the interface model (BaseInterface.set_primary_mac_address); this mixin is a thin adapter that\nowns the permission check, the shortcut-vs-primary_mac_address conflict guard, and translating the\nmodel's validation errors into API errors.", "properties": { "device": { "oneOf": [ @@ -302263,6 +338110,7 @@ "virtual", "bridge", "lag", + "channel", "100base-fx", "100base-lfx", "100base-tx", @@ -302489,8 +338337,22 @@ "other" ], "type": "string", - "description": "* `virtual` - Virtual\n* `bridge` - Bridge\n* `lag` - Link Aggregation Group (LAG)\n* `100base-fx` - 100BASE-FX (10/100ME)\n* `100base-lfx` - 100BASE-LFX (10/100ME)\n* `100base-tx` - 100BASE-TX (10/100ME)\n* `100base-t1` - 100BASE-T1 (10/100ME)\n* `1000base-bx10-d` - 1000BASE-BX10-D (1GE BiDi Down)\n* `1000base-bx10-u` - 1000BASE-BX10-U (1GE BiDi Up)\n* `1000base-cwdm` - 1000BASE-CWDM (1GE)\n* `1000base-cx` - 1000BASE-CX (1GE DAC)\n* `1000base-dwdm` - 1000BASE-DWDM (1GE)\n* `1000base-ex` - 1000BASE-EX (1GE)\n* `1000base-lsx` - 1000BASE-LSX (1GE)\n* `1000base-lx` - 1000BASE-LX (1GE)\n* `1000base-lx10` - 1000BASE-LX10/LH (1GE)\n* `1000base-sx` - 1000BASE-SX (1GE)\n* `1000base-t` - 1000BASE-T (1GE)\n* `1000base-tx` - 1000BASE-TX (1GE)\n* `1000base-zx` - 1000BASE-ZX (1GE)\n* `2.5gbase-t` - 2.5GBASE-T (2.5GE)\n* `5gbase-t` - 5GBASE-T (5GE)\n* `10gbase-br-d` - 10GBASE-BR-D (10GE BiDi Down)\n* `10gbase-br-u` - 10GBASE-BR-U (10GE BiDi Up)\n* `10gbase-cu` - 10GBASE-CU (10GE DAC Passive Twinax)\n* `10gbase-cx4` - 10GBASE-CX4 (10GE DAC)\n* `10gbase-er` - 10GBASE-ER (10GE)\n* `10gbase-lr` - 10GBASE-LR (10GE)\n* `10gbase-lrm` - 10GBASE-LRM (10GE)\n* `10gbase-lx4` - 10GBASE-LX4 (10GE)\n* `10gbase-sr` - 10GBASE-SR (10GE)\n* `10gbase-t` - 10GBASE-T (10GE)\n* `10gbase-zr` - 10GBASE-ZR (10GE)\n* `25gbase-cr` - 25GBASE-CR (25GE DAC)\n* `25gbase-er` - 25GBASE-ER (25GE)\n* `25gbase-lr` - 25GBASE-LR (25GE)\n* `25gbase-sr` - 25GBASE-SR (25GE)\n* `25gbase-t` - 25GBASE-T (25GE)\n* `40gbase-cr4` - 40GBASE-CR4 (40GE DAC)\n* `40gbase-er4` - 40GBASE-ER4 (40GE)\n* `40gbase-fr4` - 40GBASE-FR4 (40GE)\n* `40gbase-lr4` - 40GBASE-LR4 (40GE)\n* `40gbase-sr4` - 40GBASE-SR4 (40GE)\n* `40gbase-sr4-bd` - 40GBASE-SR4 (40GE BiDi)\n* `50gbase-cr` - 50GBASE-CR (50GE DAC)\n* `50gbase-er` - 50GBASE-ER (50GE)\n* `50gbase-fr` - 50GBASE-FR (50GE)\n* `50gbase-lr` - 50GBASE-LR (50GE)\n* `50gbase-sr` - 50GBASE-SR (50GE)\n* `100gbase-cr1` - 100GBASE-CR1 (100GE DAC)\n* `100gbase-cr2` - 100GBASE-CR2 (100GE DAC)\n* `100gbase-cr4` - 100GBASE-CR4 (100GE DAC)\n* `100gbase-cr10` - 100GBASE-CR10 (100GE DAC)\n* `100gbase-cwdm4` - 100GBASE-CWDM4 (100GE)\n* `100gbase-dr` - 100GBASE-DR (100GE)\n* `100gbase-er4` - 100GBASE-ER4 (100GE)\n* `100gbase-fr1` - 100GBASE-FR1 (100GE)\n* `100gbase-lr1` - 100GBASE-LR1 (100GE)\n* `100gbase-lr4` - 100GBASE-LR4 (100GE)\n* `100gbase-sr1` - 100GBASE-SR1 (100GE)\n* `100gbase-sr1.2` - 100GBASE-SR1.2 (100GE BiDi)\n* `100gbase-sr2` - 100GBASE-SR2 (100GE)\n* `100gbase-sr4` - 100GBASE-SR4 (100GE)\n* `100gbase-sr10` - 100GBASE-SR10 (100GE)\n* `100gbase-zr` - 100GBASE-ZR (100GE)\n* `200gbase-cr2` - 200GBASE-CR2 (200GE)\n* `200gbase-cr4` - 200GBASE-CR4 (200GE)\n* `200gbase-dr4` - 200GBASE-DR4 (200GE)\n* `200gbase-er4` - 200GBASE-ER4 (200GE)\n* `200gbase-fr4` - 200GBASE-FR4 (200GE)\n* `200gbase-lr4` - 200GBASE-LR4 (200GE)\n* `200gbase-sr2` - 200GBASE-SR2 (200GE)\n* `200gbase-sr4` - 200GBASE-SR4 (200GE)\n* `200gbase-vr2` - 200GBASE-VR2 (200GE)\n* `400gbase-cr4` - 400GBASE-CR4 (400GE)\n* `400gbase-dr4` - 400GBASE-DR4 (400GE)\n* `400gbase-er8` - 400GBASE-ER8 (400GE)\n* `400gbase-fr4` - 400GBASE-FR4 (400GE)\n* `400gbase-fr8` - 400GBASE-FR8 (400GE)\n* `400gbase-lr4` - 400GBASE-LR4 (400GE)\n* `400gbase-lr8` - 400GBASE-LR8 (400GE)\n* `400gbase-sr4` - 400GBASE-SR4 (400GE)\n* `400gbase-sr4_2` - 400GBASE-SR4.2 (400GE BiDi)\n* `400gbase-sr8` - 400GBASE-SR8 (400GE)\n* `400gbase-sr16` - 400GBASE-SR16 (400GE)\n* `400gbase-vr4` - 400GBASE-VR4 (400GE)\n* `400gbase-zr` - 400GBASE-ZR (400GE)\n* `800gbase-cr8` - 800GBASE-CR8 (800GE)\n* `800gbase-dr8` - 800GBASE-DR8 (800GE)\n* `800gbase-sr8` - 800GBASE-SR8 (800GE)\n* `800gbase-vr8` - 800GBASE-VR8 (800GE)\n* `1.6tbase-cr8` - 1.6TBASE-CR8 (1.6TE)\n* `1.6tbase-dr8` - 1.6TBASE-DR8 (1.6TE)\n* `1.6tbase-dr8-2` - 1.6TBASE-DR8-2 (1.6TE)\n* `100base-x-sfp` - SFP (100ME)\n* `1000base-x-gbic` - GBIC (1GE)\n* `1000base-x-sfp` - SFP (1GE)\n* `2.5gbase-x-sfp` - SFP (2.5GE)\n* `10gbase-x-sfpp` - SFP+ (10GE)\n* `10gbase-x-xenpak` - XENPAK (10GE)\n* `10gbase-x-xfp` - XFP (10GE)\n* `10gbase-x-x2` - X2 (10GE)\n* `25gbase-x-sfp28` - SFP28 (25GE)\n* `40gbase-x-qsfpp` - QSFP+ (40GE)\n* `50gbase-x-sfp28` - QSFP28 (50GE)\n* `50gbase-x-sfp56` - SFP56 (50GE)\n* `100gbase-x-cfp` - CFP (100GE)\n* `100gbase-x-cfp2` - CFP2 (100GE)\n* `100gbase-x-cfp4` - CFP4 (100GE)\n* `100gbase-x-cxp` - CXP (100GE)\n* `100gbase-x-cpak` - Cisco CPAK (100GE)\n* `100gbase-x-dsfp` - DSFP (100GE)\n* `100gbase-x-qsfp28` - QSFP28 (100GE)\n* `100gbase-x-qsfpdd` - QSFP-DD (100GE)\n* `100gbase-x-sfp112` - SFP112 (100GE)\n* `100gbase-x-sfpdd` - SFP-DD (100GE)\n* `200gbase-x-cfp2` - CFP2 (200GE)\n* `200gbase-x-qsfp56` - QSFP56 (200GE)\n* `200gbase-x-qsfpdd` - QSFP-DD (200GE)\n* `400gbase-x-qsfp112` - QSFP112 (400GE)\n* `400gbase-x-qsfpdd` - QSFP-DD (400GE)\n* `400gbase-x-cdfp` - CDFP (400GE)\n* `400gbase-x-cfp2` - CFP2 (400GE)\n* `400gbase-x-cfp8` - CPF8 (400GE)\n* `400gbase-x-osfp` - OSFP (400GE)\n* `400gbase-x-osfp-rhs` - OSFP-RHS (400GE)\n* `800gbase-x-osfp` - OSFP (800GE)\n* `800gbase-x-qsfpdd` - QSFP-DD (800GE)\n* `1.6tbase-x-osfp1600` - OSFP1600 (1.6TE)\n* `1.6tbase-x-osfp1600-rhs` - OSFP1600-RHS (1.6TE)\n* `1.6tbase-x-qsfpdd1600` - QSFP-DD1600 (1.6TE)\n* `1000base-kx` - 1000BASE-KX (1GE)\n* `2.5gbase-kx` - 2.5GBASE-KX (2.5GE)\n* `5gbase-kr` - 5GBASE-KR (5GE)\n* `10gbase-kr` - 10GBASE-KR (10GE)\n* `10gbase-kx4` - 10GBASE-KX4 (10GE)\n* `25gbase-kr` - 25GBASE-KR (25GE)\n* `40gbase-kr4` - 40GBASE-KR4 (40GE)\n* `50gbase-kr` - 50GBASE-KR (50GE)\n* `100gbase-kp4` - 100GBASE-KP4 (100GE)\n* `100gbase-kr2` - 100GBASE-KR2 (100GE)\n* `100gbase-kr4` - 100GBASE-KR4 (100GE)\n* `1.6tbase-kr8` - 1.6TBASE-KR8 (1.6TE)\n* `ieee802.11a` - IEEE 802.11a\n* `ieee802.11g` - IEEE 802.11b/g\n* `ieee802.11n` - IEEE 802.11n (Wi-Fi 4)\n* `ieee802.11ac` - IEEE 802.11ac (Wi-Fi 5)\n* `ieee802.11ad` - IEEE 802.11ad (WiGig)\n* `ieee802.11ax` - IEEE 802.11ax (Wi-Fi 6)\n* `ieee802.11ay` - IEEE 802.11ay (WiGig)\n* `ieee802.11be` - IEEE 802.11be (Wi-Fi 7)\n* `ieee802.15.1` - IEEE 802.15.1 (Bluetooth)\n* `ieee802.15.4` - IEEE 802.15.4 (LR-WPAN)\n* `other-wireless` - Other (Wireless)\n* `gsm` - GSM\n* `cdma` - CDMA\n* `lte` - LTE\n* `4g` - 4G\n* `5g` - 5G\n* `sonet-oc3` - OC-3/STM-1\n* `sonet-oc12` - OC-12/STM-4\n* `sonet-oc48` - OC-48/STM-16\n* `sonet-oc192` - OC-192/STM-64\n* `sonet-oc768` - OC-768/STM-256\n* `sonet-oc1920` - OC-1920/STM-640\n* `sonet-oc3840` - OC-3840/STM-1234\n* `1gfc-sfp` - SFP (1GFC)\n* `2gfc-sfp` - SFP (2GFC)\n* `4gfc-sfp` - SFP (4GFC)\n* `8gfc-sfpp` - SFP+ (8GFC)\n* `16gfc-sfpp` - SFP+ (16GFC)\n* `32gfc-sfp28` - SFP28 (32GFC)\n* `32gfc-sfpp` - SFP+ (32GFC)\n* `64gfc-qsfpp` - QSFP+ (64GFC)\n* `64gfc-sfpdd` - SFP-DD (64GFC)\n* `64gfc-sfpp` - SFP+ (64GFC)\n* `128gfc-qsfp28` - QSFP28 (128GFC)\n* `infiniband-sdr` - SDR (2 Gbps)\n* `infiniband-ddr` - DDR (4 Gbps)\n* `infiniband-qdr` - QDR (8 Gbps)\n* `infiniband-fdr10` - FDR10 (10 Gbps)\n* `infiniband-fdr` - FDR (13.5 Gbps)\n* `infiniband-edr` - EDR (25 Gbps)\n* `infiniband-hdr` - HDR (50 Gbps)\n* `infiniband-ndr` - NDR (100 Gbps)\n* `infiniband-xdr` - XDR (200 Gbps)\n* `infiniband-sdr-4x` - SDR 4X (8 Gbps)\n* `infiniband-ddr-4x` - DDR 4X (16 Gbps)\n* `infiniband-qdr-4x` - QDR 4X (32 Gbps)\n* `infiniband-fdr10-4x` - FDR10 4X (40 Gbps)\n* `infiniband-fdr-4x` - FDR 4X (56 Gbps)\n* `infiniband-edr-4x` - EDR 4X (100 Gbps)\n* `infiniband-hdr-4x` - HDR 4X (200 Gbps)\n* `infiniband-ndr-4x` - NDR 4X (400 Gbps)\n* `infiniband-xdr-4x` - XDR 4X (800 Gbps)\n* `t1` - T1 (1.544 Mbps)\n* `e1` - E1 (2.048 Mbps)\n* `t3` - T3 (45 Mbps)\n* `e3` - E3 (34 Mbps)\n* `xdsl` - xDSL\n* `docsis` - DOCSIS\n* `moca` - MoCA\n* `bpon` - BPON (622 Mbps / 155 Mbps)\n* `epon` - EPON (1 Gbps)\n* `10g-epon` - 10G-EPON (10 Gbps)\n* `gpon` - GPON (2.5 Gbps / 1.25 Gbps)\n* `xg-pon` - XG-PON (10 Gbps / 2.5 Gbps)\n* `xgs-pon` - XGS-PON (10 Gbps)\n* `ng-pon2` - NG-PON2 (TWDM-PON) (4x10 Gbps)\n* `25g-pon` - 25G-PON (25 Gbps)\n* `50g-pon` - 50G-PON (50 Gbps)\n* `cisco-stackwise` - Cisco StackWise\n* `cisco-stackwise-plus` - Cisco StackWise Plus\n* `cisco-flexstack` - Cisco FlexStack\n* `cisco-flexstack-plus` - Cisco FlexStack Plus\n* `cisco-stackwise-80` - Cisco StackWise-80\n* `cisco-stackwise-160` - Cisco StackWise-160\n* `cisco-stackwise-320` - Cisco StackWise-320\n* `cisco-stackwise-480` - Cisco StackWise-480\n* `cisco-stackwise-1t` - Cisco StackWise-1T\n* `juniper-vcp` - Juniper VCP\n* `extreme-summitstack` - Extreme SummitStack\n* `extreme-summitstack-128` - Extreme SummitStack-128\n* `extreme-summitstack-256` - Extreme SummitStack-256\n* `extreme-summitstack-512` - Extreme SummitStack-512\n* `hpe-synergy-interconnect-link` - HPE Synergy Interconnect Link\n* `other` - Other", - "x-spec-enum-id": "b0c97040e5abdff1" + "description": "* `virtual` - Virtual\n* `bridge` - Bridge\n* `lag` - Link Aggregation Group (LAG)\n* `channel` - Channel\n* `100base-fx` - 100BASE-FX (10/100ME)\n* `100base-lfx` - 100BASE-LFX (10/100ME)\n* `100base-tx` - 100BASE-TX (10/100ME)\n* `100base-t1` - 100BASE-T1 (10/100ME)\n* `1000base-bx10-d` - 1000BASE-BX10-D (1GE BiDi Down)\n* `1000base-bx10-u` - 1000BASE-BX10-U (1GE BiDi Up)\n* `1000base-cwdm` - 1000BASE-CWDM (1GE)\n* `1000base-cx` - 1000BASE-CX (1GE DAC)\n* `1000base-dwdm` - 1000BASE-DWDM (1GE)\n* `1000base-ex` - 1000BASE-EX (1GE)\n* `1000base-lsx` - 1000BASE-LSX (1GE)\n* `1000base-lx` - 1000BASE-LX (1GE)\n* `1000base-lx10` - 1000BASE-LX10/LH (1GE)\n* `1000base-sx` - 1000BASE-SX (1GE)\n* `1000base-t` - 1000BASE-T (1GE)\n* `1000base-tx` - 1000BASE-TX (1GE)\n* `1000base-zx` - 1000BASE-ZX (1GE)\n* `2.5gbase-t` - 2.5GBASE-T (2.5GE)\n* `5gbase-t` - 5GBASE-T (5GE)\n* `10gbase-br-d` - 10GBASE-BR-D (10GE BiDi Down)\n* `10gbase-br-u` - 10GBASE-BR-U (10GE BiDi Up)\n* `10gbase-cu` - 10GBASE-CU (10GE DAC Passive Twinax)\n* `10gbase-cx4` - 10GBASE-CX4 (10GE DAC)\n* `10gbase-er` - 10GBASE-ER (10GE)\n* `10gbase-lr` - 10GBASE-LR (10GE)\n* `10gbase-lrm` - 10GBASE-LRM (10GE)\n* `10gbase-lx4` - 10GBASE-LX4 (10GE)\n* `10gbase-sr` - 10GBASE-SR (10GE)\n* `10gbase-t` - 10GBASE-T (10GE)\n* `10gbase-zr` - 10GBASE-ZR (10GE)\n* `25gbase-cr` - 25GBASE-CR (25GE DAC)\n* `25gbase-er` - 25GBASE-ER (25GE)\n* `25gbase-lr` - 25GBASE-LR (25GE)\n* `25gbase-sr` - 25GBASE-SR (25GE)\n* `25gbase-t` - 25GBASE-T (25GE)\n* `40gbase-cr4` - 40GBASE-CR4 (40GE DAC)\n* `40gbase-er4` - 40GBASE-ER4 (40GE)\n* `40gbase-fr4` - 40GBASE-FR4 (40GE)\n* `40gbase-lr4` - 40GBASE-LR4 (40GE)\n* `40gbase-sr4` - 40GBASE-SR4 (40GE)\n* `40gbase-sr4-bd` - 40GBASE-SR4 (40GE BiDi)\n* `50gbase-cr` - 50GBASE-CR (50GE DAC)\n* `50gbase-er` - 50GBASE-ER (50GE)\n* `50gbase-fr` - 50GBASE-FR (50GE)\n* `50gbase-lr` - 50GBASE-LR (50GE)\n* `50gbase-sr` - 50GBASE-SR (50GE)\n* `100gbase-cr1` - 100GBASE-CR1 (100GE DAC)\n* `100gbase-cr2` - 100GBASE-CR2 (100GE DAC)\n* `100gbase-cr4` - 100GBASE-CR4 (100GE DAC)\n* `100gbase-cr10` - 100GBASE-CR10 (100GE DAC)\n* `100gbase-cwdm4` - 100GBASE-CWDM4 (100GE)\n* `100gbase-dr` - 100GBASE-DR (100GE)\n* `100gbase-er4` - 100GBASE-ER4 (100GE)\n* `100gbase-fr1` - 100GBASE-FR1 (100GE)\n* `100gbase-lr1` - 100GBASE-LR1 (100GE)\n* `100gbase-lr4` - 100GBASE-LR4 (100GE)\n* `100gbase-sr1` - 100GBASE-SR1 (100GE)\n* `100gbase-sr1.2` - 100GBASE-SR1.2 (100GE BiDi)\n* `100gbase-sr2` - 100GBASE-SR2 (100GE)\n* `100gbase-sr4` - 100GBASE-SR4 (100GE)\n* `100gbase-sr10` - 100GBASE-SR10 (100GE)\n* `100gbase-zr` - 100GBASE-ZR (100GE)\n* `200gbase-cr2` - 200GBASE-CR2 (200GE)\n* `200gbase-cr4` - 200GBASE-CR4 (200GE)\n* `200gbase-dr4` - 200GBASE-DR4 (200GE)\n* `200gbase-er4` - 200GBASE-ER4 (200GE)\n* `200gbase-fr4` - 200GBASE-FR4 (200GE)\n* `200gbase-lr4` - 200GBASE-LR4 (200GE)\n* `200gbase-sr2` - 200GBASE-SR2 (200GE)\n* `200gbase-sr4` - 200GBASE-SR4 (200GE)\n* `200gbase-vr2` - 200GBASE-VR2 (200GE)\n* `400gbase-cr4` - 400GBASE-CR4 (400GE)\n* `400gbase-dr4` - 400GBASE-DR4 (400GE)\n* `400gbase-er8` - 400GBASE-ER8 (400GE)\n* `400gbase-fr4` - 400GBASE-FR4 (400GE)\n* `400gbase-fr8` - 400GBASE-FR8 (400GE)\n* `400gbase-lr4` - 400GBASE-LR4 (400GE)\n* `400gbase-lr8` - 400GBASE-LR8 (400GE)\n* `400gbase-sr4` - 400GBASE-SR4 (400GE)\n* `400gbase-sr4_2` - 400GBASE-SR4.2 (400GE BiDi)\n* `400gbase-sr8` - 400GBASE-SR8 (400GE)\n* `400gbase-sr16` - 400GBASE-SR16 (400GE)\n* `400gbase-vr4` - 400GBASE-VR4 (400GE)\n* `400gbase-zr` - 400GBASE-ZR (400GE)\n* `800gbase-cr8` - 800GBASE-CR8 (800GE)\n* `800gbase-dr8` - 800GBASE-DR8 (800GE)\n* `800gbase-sr8` - 800GBASE-SR8 (800GE)\n* `800gbase-vr8` - 800GBASE-VR8 (800GE)\n* `1.6tbase-cr8` - 1.6TBASE-CR8 (1.6TE)\n* `1.6tbase-dr8` - 1.6TBASE-DR8 (1.6TE)\n* `1.6tbase-dr8-2` - 1.6TBASE-DR8-2 (1.6TE)\n* `100base-x-sfp` - SFP (100ME)\n* `1000base-x-gbic` - GBIC (1GE)\n* `1000base-x-sfp` - SFP (1GE)\n* `2.5gbase-x-sfp` - SFP (2.5GE)\n* `10gbase-x-sfpp` - SFP+ (10GE)\n* `10gbase-x-xenpak` - XENPAK (10GE)\n* `10gbase-x-xfp` - XFP (10GE)\n* `10gbase-x-x2` - X2 (10GE)\n* `25gbase-x-sfp28` - SFP28 (25GE)\n* `40gbase-x-qsfpp` - QSFP+ (40GE)\n* `50gbase-x-sfp28` - QSFP28 (50GE)\n* `50gbase-x-sfp56` - SFP56 (50GE)\n* `100gbase-x-cfp` - CFP (100GE)\n* `100gbase-x-cfp2` - CFP2 (100GE)\n* `100gbase-x-cfp4` - CFP4 (100GE)\n* `100gbase-x-cxp` - CXP (100GE)\n* `100gbase-x-cpak` - Cisco CPAK (100GE)\n* `100gbase-x-dsfp` - DSFP (100GE)\n* `100gbase-x-qsfp28` - QSFP28 (100GE)\n* `100gbase-x-qsfpdd` - QSFP-DD (100GE)\n* `100gbase-x-sfp112` - SFP112 (100GE)\n* `100gbase-x-sfpdd` - SFP-DD (100GE)\n* `200gbase-x-cfp2` - CFP2 (200GE)\n* `200gbase-x-qsfp56` - QSFP56 (200GE)\n* `200gbase-x-qsfpdd` - QSFP-DD (200GE)\n* `400gbase-x-qsfp112` - QSFP112 (400GE)\n* `400gbase-x-qsfpdd` - QSFP-DD (400GE)\n* `400gbase-x-cdfp` - CDFP (400GE)\n* `400gbase-x-cfp2` - CFP2 (400GE)\n* `400gbase-x-cfp8` - CPF8 (400GE)\n* `400gbase-x-osfp` - OSFP (400GE)\n* `400gbase-x-osfp-rhs` - OSFP-RHS (400GE)\n* `800gbase-x-osfp` - OSFP (800GE)\n* `800gbase-x-qsfpdd` - QSFP-DD (800GE)\n* `1.6tbase-x-osfp1600` - OSFP1600 (1.6TE)\n* `1.6tbase-x-osfp1600-rhs` - OSFP1600-RHS (1.6TE)\n* `1.6tbase-x-qsfpdd1600` - QSFP-DD1600 (1.6TE)\n* `1000base-kx` - 1000BASE-KX (1GE)\n* `2.5gbase-kx` - 2.5GBASE-KX (2.5GE)\n* `5gbase-kr` - 5GBASE-KR (5GE)\n* `10gbase-kr` - 10GBASE-KR (10GE)\n* `10gbase-kx4` - 10GBASE-KX4 (10GE)\n* `25gbase-kr` - 25GBASE-KR (25GE)\n* `40gbase-kr4` - 40GBASE-KR4 (40GE)\n* `50gbase-kr` - 50GBASE-KR (50GE)\n* `100gbase-kp4` - 100GBASE-KP4 (100GE)\n* `100gbase-kr2` - 100GBASE-KR2 (100GE)\n* `100gbase-kr4` - 100GBASE-KR4 (100GE)\n* `1.6tbase-kr8` - 1.6TBASE-KR8 (1.6TE)\n* `ieee802.11a` - IEEE 802.11a\n* `ieee802.11g` - IEEE 802.11b/g\n* `ieee802.11n` - IEEE 802.11n (Wi-Fi 4)\n* `ieee802.11ac` - IEEE 802.11ac (Wi-Fi 5)\n* `ieee802.11ad` - IEEE 802.11ad (WiGig)\n* `ieee802.11ax` - IEEE 802.11ax (Wi-Fi 6)\n* `ieee802.11ay` - IEEE 802.11ay (WiGig)\n* `ieee802.11be` - IEEE 802.11be (Wi-Fi 7)\n* `ieee802.15.1` - IEEE 802.15.1 (Bluetooth)\n* `ieee802.15.4` - IEEE 802.15.4 (LR-WPAN)\n* `other-wireless` - Other (Wireless)\n* `gsm` - GSM\n* `cdma` - CDMA\n* `lte` - LTE\n* `4g` - 4G\n* `5g` - 5G\n* `sonet-oc3` - OC-3/STM-1\n* `sonet-oc12` - OC-12/STM-4\n* `sonet-oc48` - OC-48/STM-16\n* `sonet-oc192` - OC-192/STM-64\n* `sonet-oc768` - OC-768/STM-256\n* `sonet-oc1920` - OC-1920/STM-640\n* `sonet-oc3840` - OC-3840/STM-1234\n* `1gfc-sfp` - SFP (1GFC)\n* `2gfc-sfp` - SFP (2GFC)\n* `4gfc-sfp` - SFP (4GFC)\n* `8gfc-sfpp` - SFP+ (8GFC)\n* `16gfc-sfpp` - SFP+ (16GFC)\n* `32gfc-sfp28` - SFP28 (32GFC)\n* `32gfc-sfpp` - SFP+ (32GFC)\n* `64gfc-qsfpp` - QSFP+ (64GFC)\n* `64gfc-sfpdd` - SFP-DD (64GFC)\n* `64gfc-sfpp` - SFP+ (64GFC)\n* `128gfc-qsfp28` - QSFP28 (128GFC)\n* `infiniband-sdr` - SDR (2 Gbps)\n* `infiniband-ddr` - DDR (4 Gbps)\n* `infiniband-qdr` - QDR (8 Gbps)\n* `infiniband-fdr10` - FDR10 (10 Gbps)\n* `infiniband-fdr` - FDR (13.5 Gbps)\n* `infiniband-edr` - EDR (25 Gbps)\n* `infiniband-hdr` - HDR (50 Gbps)\n* `infiniband-ndr` - NDR (100 Gbps)\n* `infiniband-xdr` - XDR (200 Gbps)\n* `infiniband-sdr-4x` - SDR 4X (8 Gbps)\n* `infiniband-ddr-4x` - DDR 4X (16 Gbps)\n* `infiniband-qdr-4x` - QDR 4X (32 Gbps)\n* `infiniband-fdr10-4x` - FDR10 4X (40 Gbps)\n* `infiniband-fdr-4x` - FDR 4X (56 Gbps)\n* `infiniband-edr-4x` - EDR 4X (100 Gbps)\n* `infiniband-hdr-4x` - HDR 4X (200 Gbps)\n* `infiniband-ndr-4x` - NDR 4X (400 Gbps)\n* `infiniband-xdr-4x` - XDR 4X (800 Gbps)\n* `t1` - T1 (1.544 Mbps)\n* `e1` - E1 (2.048 Mbps)\n* `t3` - T3 (45 Mbps)\n* `e3` - E3 (34 Mbps)\n* `xdsl` - xDSL\n* `docsis` - DOCSIS\n* `moca` - MoCA\n* `bpon` - BPON (622 Mbps / 155 Mbps)\n* `epon` - EPON (1 Gbps)\n* `10g-epon` - 10G-EPON (10 Gbps)\n* `gpon` - GPON (2.5 Gbps / 1.25 Gbps)\n* `xg-pon` - XG-PON (10 Gbps / 2.5 Gbps)\n* `xgs-pon` - XGS-PON (10 Gbps)\n* `ng-pon2` - NG-PON2 (TWDM-PON) (4x10 Gbps)\n* `25g-pon` - 25G-PON (25 Gbps)\n* `50g-pon` - 50G-PON (50 Gbps)\n* `cisco-stackwise` - Cisco StackWise\n* `cisco-stackwise-plus` - Cisco StackWise Plus\n* `cisco-flexstack` - Cisco FlexStack\n* `cisco-flexstack-plus` - Cisco FlexStack Plus\n* `cisco-stackwise-80` - Cisco StackWise-80\n* `cisco-stackwise-160` - Cisco StackWise-160\n* `cisco-stackwise-320` - Cisco StackWise-320\n* `cisco-stackwise-480` - Cisco StackWise-480\n* `cisco-stackwise-1t` - Cisco StackWise-1T\n* `juniper-vcp` - Juniper VCP\n* `extreme-summitstack` - Extreme SummitStack\n* `extreme-summitstack-128` - Extreme SummitStack-128\n* `extreme-summitstack-256` - Extreme SummitStack-256\n* `extreme-summitstack-512` - Extreme SummitStack-512\n* `hpe-synergy-interconnect-link` - HPE Synergy Interconnect Link\n* `other` - Other", + "x-spec-enum-id": "19cc901fcea417ad" + }, + "channels": { + "type": "integer", + "maximum": 1024, + "minimum": 1, + "nullable": true, + "description": "The number of channels into which this interface is channelized" + }, + "channel_id": { + "type": "integer", + "maximum": 1024, + "minimum": 1, + "nullable": true, + "description": "The channel on the parent interface to which this subinterface is bound" }, "enabled": { "type": "boolean" @@ -302516,6 +338378,11 @@ "minimum": 1, "nullable": true }, + "mac_address": { + "type": "string", + "nullable": true, + "minLength": 1 + }, "primary_mac_address": { "oneOf": [ { @@ -303020,6 +338887,7 @@ "virtual", "bridge", "lag", + "channel", "100base-fx", "100base-lfx", "100base-tx", @@ -303246,8 +339114,22 @@ "other" ], "type": "string", - "description": "* `virtual` - Virtual\n* `bridge` - Bridge\n* `lag` - Link Aggregation Group (LAG)\n* `100base-fx` - 100BASE-FX (10/100ME)\n* `100base-lfx` - 100BASE-LFX (10/100ME)\n* `100base-tx` - 100BASE-TX (10/100ME)\n* `100base-t1` - 100BASE-T1 (10/100ME)\n* `1000base-bx10-d` - 1000BASE-BX10-D (1GE BiDi Down)\n* `1000base-bx10-u` - 1000BASE-BX10-U (1GE BiDi Up)\n* `1000base-cwdm` - 1000BASE-CWDM (1GE)\n* `1000base-cx` - 1000BASE-CX (1GE DAC)\n* `1000base-dwdm` - 1000BASE-DWDM (1GE)\n* `1000base-ex` - 1000BASE-EX (1GE)\n* `1000base-lsx` - 1000BASE-LSX (1GE)\n* `1000base-lx` - 1000BASE-LX (1GE)\n* `1000base-lx10` - 1000BASE-LX10/LH (1GE)\n* `1000base-sx` - 1000BASE-SX (1GE)\n* `1000base-t` - 1000BASE-T (1GE)\n* `1000base-tx` - 1000BASE-TX (1GE)\n* `1000base-zx` - 1000BASE-ZX (1GE)\n* `2.5gbase-t` - 2.5GBASE-T (2.5GE)\n* `5gbase-t` - 5GBASE-T (5GE)\n* `10gbase-br-d` - 10GBASE-BR-D (10GE BiDi Down)\n* `10gbase-br-u` - 10GBASE-BR-U (10GE BiDi Up)\n* `10gbase-cu` - 10GBASE-CU (10GE DAC Passive Twinax)\n* `10gbase-cx4` - 10GBASE-CX4 (10GE DAC)\n* `10gbase-er` - 10GBASE-ER (10GE)\n* `10gbase-lr` - 10GBASE-LR (10GE)\n* `10gbase-lrm` - 10GBASE-LRM (10GE)\n* `10gbase-lx4` - 10GBASE-LX4 (10GE)\n* `10gbase-sr` - 10GBASE-SR (10GE)\n* `10gbase-t` - 10GBASE-T (10GE)\n* `10gbase-zr` - 10GBASE-ZR (10GE)\n* `25gbase-cr` - 25GBASE-CR (25GE DAC)\n* `25gbase-er` - 25GBASE-ER (25GE)\n* `25gbase-lr` - 25GBASE-LR (25GE)\n* `25gbase-sr` - 25GBASE-SR (25GE)\n* `25gbase-t` - 25GBASE-T (25GE)\n* `40gbase-cr4` - 40GBASE-CR4 (40GE DAC)\n* `40gbase-er4` - 40GBASE-ER4 (40GE)\n* `40gbase-fr4` - 40GBASE-FR4 (40GE)\n* `40gbase-lr4` - 40GBASE-LR4 (40GE)\n* `40gbase-sr4` - 40GBASE-SR4 (40GE)\n* `40gbase-sr4-bd` - 40GBASE-SR4 (40GE BiDi)\n* `50gbase-cr` - 50GBASE-CR (50GE DAC)\n* `50gbase-er` - 50GBASE-ER (50GE)\n* `50gbase-fr` - 50GBASE-FR (50GE)\n* `50gbase-lr` - 50GBASE-LR (50GE)\n* `50gbase-sr` - 50GBASE-SR (50GE)\n* `100gbase-cr1` - 100GBASE-CR1 (100GE DAC)\n* `100gbase-cr2` - 100GBASE-CR2 (100GE DAC)\n* `100gbase-cr4` - 100GBASE-CR4 (100GE DAC)\n* `100gbase-cr10` - 100GBASE-CR10 (100GE DAC)\n* `100gbase-cwdm4` - 100GBASE-CWDM4 (100GE)\n* `100gbase-dr` - 100GBASE-DR (100GE)\n* `100gbase-er4` - 100GBASE-ER4 (100GE)\n* `100gbase-fr1` - 100GBASE-FR1 (100GE)\n* `100gbase-lr1` - 100GBASE-LR1 (100GE)\n* `100gbase-lr4` - 100GBASE-LR4 (100GE)\n* `100gbase-sr1` - 100GBASE-SR1 (100GE)\n* `100gbase-sr1.2` - 100GBASE-SR1.2 (100GE BiDi)\n* `100gbase-sr2` - 100GBASE-SR2 (100GE)\n* `100gbase-sr4` - 100GBASE-SR4 (100GE)\n* `100gbase-sr10` - 100GBASE-SR10 (100GE)\n* `100gbase-zr` - 100GBASE-ZR (100GE)\n* `200gbase-cr2` - 200GBASE-CR2 (200GE)\n* `200gbase-cr4` - 200GBASE-CR4 (200GE)\n* `200gbase-dr4` - 200GBASE-DR4 (200GE)\n* `200gbase-er4` - 200GBASE-ER4 (200GE)\n* `200gbase-fr4` - 200GBASE-FR4 (200GE)\n* `200gbase-lr4` - 200GBASE-LR4 (200GE)\n* `200gbase-sr2` - 200GBASE-SR2 (200GE)\n* `200gbase-sr4` - 200GBASE-SR4 (200GE)\n* `200gbase-vr2` - 200GBASE-VR2 (200GE)\n* `400gbase-cr4` - 400GBASE-CR4 (400GE)\n* `400gbase-dr4` - 400GBASE-DR4 (400GE)\n* `400gbase-er8` - 400GBASE-ER8 (400GE)\n* `400gbase-fr4` - 400GBASE-FR4 (400GE)\n* `400gbase-fr8` - 400GBASE-FR8 (400GE)\n* `400gbase-lr4` - 400GBASE-LR4 (400GE)\n* `400gbase-lr8` - 400GBASE-LR8 (400GE)\n* `400gbase-sr4` - 400GBASE-SR4 (400GE)\n* `400gbase-sr4_2` - 400GBASE-SR4.2 (400GE BiDi)\n* `400gbase-sr8` - 400GBASE-SR8 (400GE)\n* `400gbase-sr16` - 400GBASE-SR16 (400GE)\n* `400gbase-vr4` - 400GBASE-VR4 (400GE)\n* `400gbase-zr` - 400GBASE-ZR (400GE)\n* `800gbase-cr8` - 800GBASE-CR8 (800GE)\n* `800gbase-dr8` - 800GBASE-DR8 (800GE)\n* `800gbase-sr8` - 800GBASE-SR8 (800GE)\n* `800gbase-vr8` - 800GBASE-VR8 (800GE)\n* `1.6tbase-cr8` - 1.6TBASE-CR8 (1.6TE)\n* `1.6tbase-dr8` - 1.6TBASE-DR8 (1.6TE)\n* `1.6tbase-dr8-2` - 1.6TBASE-DR8-2 (1.6TE)\n* `100base-x-sfp` - SFP (100ME)\n* `1000base-x-gbic` - GBIC (1GE)\n* `1000base-x-sfp` - SFP (1GE)\n* `2.5gbase-x-sfp` - SFP (2.5GE)\n* `10gbase-x-sfpp` - SFP+ (10GE)\n* `10gbase-x-xenpak` - XENPAK (10GE)\n* `10gbase-x-xfp` - XFP (10GE)\n* `10gbase-x-x2` - X2 (10GE)\n* `25gbase-x-sfp28` - SFP28 (25GE)\n* `40gbase-x-qsfpp` - QSFP+ (40GE)\n* `50gbase-x-sfp28` - QSFP28 (50GE)\n* `50gbase-x-sfp56` - SFP56 (50GE)\n* `100gbase-x-cfp` - CFP (100GE)\n* `100gbase-x-cfp2` - CFP2 (100GE)\n* `100gbase-x-cfp4` - CFP4 (100GE)\n* `100gbase-x-cxp` - CXP (100GE)\n* `100gbase-x-cpak` - Cisco CPAK (100GE)\n* `100gbase-x-dsfp` - DSFP (100GE)\n* `100gbase-x-qsfp28` - QSFP28 (100GE)\n* `100gbase-x-qsfpdd` - QSFP-DD (100GE)\n* `100gbase-x-sfp112` - SFP112 (100GE)\n* `100gbase-x-sfpdd` - SFP-DD (100GE)\n* `200gbase-x-cfp2` - CFP2 (200GE)\n* `200gbase-x-qsfp56` - QSFP56 (200GE)\n* `200gbase-x-qsfpdd` - QSFP-DD (200GE)\n* `400gbase-x-qsfp112` - QSFP112 (400GE)\n* `400gbase-x-qsfpdd` - QSFP-DD (400GE)\n* `400gbase-x-cdfp` - CDFP (400GE)\n* `400gbase-x-cfp2` - CFP2 (400GE)\n* `400gbase-x-cfp8` - CPF8 (400GE)\n* `400gbase-x-osfp` - OSFP (400GE)\n* `400gbase-x-osfp-rhs` - OSFP-RHS (400GE)\n* `800gbase-x-osfp` - OSFP (800GE)\n* `800gbase-x-qsfpdd` - QSFP-DD (800GE)\n* `1.6tbase-x-osfp1600` - OSFP1600 (1.6TE)\n* `1.6tbase-x-osfp1600-rhs` - OSFP1600-RHS (1.6TE)\n* `1.6tbase-x-qsfpdd1600` - QSFP-DD1600 (1.6TE)\n* `1000base-kx` - 1000BASE-KX (1GE)\n* `2.5gbase-kx` - 2.5GBASE-KX (2.5GE)\n* `5gbase-kr` - 5GBASE-KR (5GE)\n* `10gbase-kr` - 10GBASE-KR (10GE)\n* `10gbase-kx4` - 10GBASE-KX4 (10GE)\n* `25gbase-kr` - 25GBASE-KR (25GE)\n* `40gbase-kr4` - 40GBASE-KR4 (40GE)\n* `50gbase-kr` - 50GBASE-KR (50GE)\n* `100gbase-kp4` - 100GBASE-KP4 (100GE)\n* `100gbase-kr2` - 100GBASE-KR2 (100GE)\n* `100gbase-kr4` - 100GBASE-KR4 (100GE)\n* `1.6tbase-kr8` - 1.6TBASE-KR8 (1.6TE)\n* `ieee802.11a` - IEEE 802.11a\n* `ieee802.11g` - IEEE 802.11b/g\n* `ieee802.11n` - IEEE 802.11n (Wi-Fi 4)\n* `ieee802.11ac` - IEEE 802.11ac (Wi-Fi 5)\n* `ieee802.11ad` - IEEE 802.11ad (WiGig)\n* `ieee802.11ax` - IEEE 802.11ax (Wi-Fi 6)\n* `ieee802.11ay` - IEEE 802.11ay (WiGig)\n* `ieee802.11be` - IEEE 802.11be (Wi-Fi 7)\n* `ieee802.15.1` - IEEE 802.15.1 (Bluetooth)\n* `ieee802.15.4` - IEEE 802.15.4 (LR-WPAN)\n* `other-wireless` - Other (Wireless)\n* `gsm` - GSM\n* `cdma` - CDMA\n* `lte` - LTE\n* `4g` - 4G\n* `5g` - 5G\n* `sonet-oc3` - OC-3/STM-1\n* `sonet-oc12` - OC-12/STM-4\n* `sonet-oc48` - OC-48/STM-16\n* `sonet-oc192` - OC-192/STM-64\n* `sonet-oc768` - OC-768/STM-256\n* `sonet-oc1920` - OC-1920/STM-640\n* `sonet-oc3840` - OC-3840/STM-1234\n* `1gfc-sfp` - SFP (1GFC)\n* `2gfc-sfp` - SFP (2GFC)\n* `4gfc-sfp` - SFP (4GFC)\n* `8gfc-sfpp` - SFP+ (8GFC)\n* `16gfc-sfpp` - SFP+ (16GFC)\n* `32gfc-sfp28` - SFP28 (32GFC)\n* `32gfc-sfpp` - SFP+ (32GFC)\n* `64gfc-qsfpp` - QSFP+ (64GFC)\n* `64gfc-sfpdd` - SFP-DD (64GFC)\n* `64gfc-sfpp` - SFP+ (64GFC)\n* `128gfc-qsfp28` - QSFP28 (128GFC)\n* `infiniband-sdr` - SDR (2 Gbps)\n* `infiniband-ddr` - DDR (4 Gbps)\n* `infiniband-qdr` - QDR (8 Gbps)\n* `infiniband-fdr10` - FDR10 (10 Gbps)\n* `infiniband-fdr` - FDR (13.5 Gbps)\n* `infiniband-edr` - EDR (25 Gbps)\n* `infiniband-hdr` - HDR (50 Gbps)\n* `infiniband-ndr` - NDR (100 Gbps)\n* `infiniband-xdr` - XDR (200 Gbps)\n* `infiniband-sdr-4x` - SDR 4X (8 Gbps)\n* `infiniband-ddr-4x` - DDR 4X (16 Gbps)\n* `infiniband-qdr-4x` - QDR 4X (32 Gbps)\n* `infiniband-fdr10-4x` - FDR10 4X (40 Gbps)\n* `infiniband-fdr-4x` - FDR 4X (56 Gbps)\n* `infiniband-edr-4x` - EDR 4X (100 Gbps)\n* `infiniband-hdr-4x` - HDR 4X (200 Gbps)\n* `infiniband-ndr-4x` - NDR 4X (400 Gbps)\n* `infiniband-xdr-4x` - XDR 4X (800 Gbps)\n* `t1` - T1 (1.544 Mbps)\n* `e1` - E1 (2.048 Mbps)\n* `t3` - T3 (45 Mbps)\n* `e3` - E3 (34 Mbps)\n* `xdsl` - xDSL\n* `docsis` - DOCSIS\n* `moca` - MoCA\n* `bpon` - BPON (622 Mbps / 155 Mbps)\n* `epon` - EPON (1 Gbps)\n* `10g-epon` - 10G-EPON (10 Gbps)\n* `gpon` - GPON (2.5 Gbps / 1.25 Gbps)\n* `xg-pon` - XG-PON (10 Gbps / 2.5 Gbps)\n* `xgs-pon` - XGS-PON (10 Gbps)\n* `ng-pon2` - NG-PON2 (TWDM-PON) (4x10 Gbps)\n* `25g-pon` - 25G-PON (25 Gbps)\n* `50g-pon` - 50G-PON (50 Gbps)\n* `cisco-stackwise` - Cisco StackWise\n* `cisco-stackwise-plus` - Cisco StackWise Plus\n* `cisco-flexstack` - Cisco FlexStack\n* `cisco-flexstack-plus` - Cisco FlexStack Plus\n* `cisco-stackwise-80` - Cisco StackWise-80\n* `cisco-stackwise-160` - Cisco StackWise-160\n* `cisco-stackwise-320` - Cisco StackWise-320\n* `cisco-stackwise-480` - Cisco StackWise-480\n* `cisco-stackwise-1t` - Cisco StackWise-1T\n* `juniper-vcp` - Juniper VCP\n* `extreme-summitstack` - Extreme SummitStack\n* `extreme-summitstack-128` - Extreme SummitStack-128\n* `extreme-summitstack-256` - Extreme SummitStack-256\n* `extreme-summitstack-512` - Extreme SummitStack-512\n* `hpe-synergy-interconnect-link` - HPE Synergy Interconnect Link\n* `other` - Other", - "x-spec-enum-id": "b0c97040e5abdff1" + "description": "* `virtual` - Virtual\n* `bridge` - Bridge\n* `lag` - Link Aggregation Group (LAG)\n* `channel` - Channel\n* `100base-fx` - 100BASE-FX (10/100ME)\n* `100base-lfx` - 100BASE-LFX (10/100ME)\n* `100base-tx` - 100BASE-TX (10/100ME)\n* `100base-t1` - 100BASE-T1 (10/100ME)\n* `1000base-bx10-d` - 1000BASE-BX10-D (1GE BiDi Down)\n* `1000base-bx10-u` - 1000BASE-BX10-U (1GE BiDi Up)\n* `1000base-cwdm` - 1000BASE-CWDM (1GE)\n* `1000base-cx` - 1000BASE-CX (1GE DAC)\n* `1000base-dwdm` - 1000BASE-DWDM (1GE)\n* `1000base-ex` - 1000BASE-EX (1GE)\n* `1000base-lsx` - 1000BASE-LSX (1GE)\n* `1000base-lx` - 1000BASE-LX (1GE)\n* `1000base-lx10` - 1000BASE-LX10/LH (1GE)\n* `1000base-sx` - 1000BASE-SX (1GE)\n* `1000base-t` - 1000BASE-T (1GE)\n* `1000base-tx` - 1000BASE-TX (1GE)\n* `1000base-zx` - 1000BASE-ZX (1GE)\n* `2.5gbase-t` - 2.5GBASE-T (2.5GE)\n* `5gbase-t` - 5GBASE-T (5GE)\n* `10gbase-br-d` - 10GBASE-BR-D (10GE BiDi Down)\n* `10gbase-br-u` - 10GBASE-BR-U (10GE BiDi Up)\n* `10gbase-cu` - 10GBASE-CU (10GE DAC Passive Twinax)\n* `10gbase-cx4` - 10GBASE-CX4 (10GE DAC)\n* `10gbase-er` - 10GBASE-ER (10GE)\n* `10gbase-lr` - 10GBASE-LR (10GE)\n* `10gbase-lrm` - 10GBASE-LRM (10GE)\n* `10gbase-lx4` - 10GBASE-LX4 (10GE)\n* `10gbase-sr` - 10GBASE-SR (10GE)\n* `10gbase-t` - 10GBASE-T (10GE)\n* `10gbase-zr` - 10GBASE-ZR (10GE)\n* `25gbase-cr` - 25GBASE-CR (25GE DAC)\n* `25gbase-er` - 25GBASE-ER (25GE)\n* `25gbase-lr` - 25GBASE-LR (25GE)\n* `25gbase-sr` - 25GBASE-SR (25GE)\n* `25gbase-t` - 25GBASE-T (25GE)\n* `40gbase-cr4` - 40GBASE-CR4 (40GE DAC)\n* `40gbase-er4` - 40GBASE-ER4 (40GE)\n* `40gbase-fr4` - 40GBASE-FR4 (40GE)\n* `40gbase-lr4` - 40GBASE-LR4 (40GE)\n* `40gbase-sr4` - 40GBASE-SR4 (40GE)\n* `40gbase-sr4-bd` - 40GBASE-SR4 (40GE BiDi)\n* `50gbase-cr` - 50GBASE-CR (50GE DAC)\n* `50gbase-er` - 50GBASE-ER (50GE)\n* `50gbase-fr` - 50GBASE-FR (50GE)\n* `50gbase-lr` - 50GBASE-LR (50GE)\n* `50gbase-sr` - 50GBASE-SR (50GE)\n* `100gbase-cr1` - 100GBASE-CR1 (100GE DAC)\n* `100gbase-cr2` - 100GBASE-CR2 (100GE DAC)\n* `100gbase-cr4` - 100GBASE-CR4 (100GE DAC)\n* `100gbase-cr10` - 100GBASE-CR10 (100GE DAC)\n* `100gbase-cwdm4` - 100GBASE-CWDM4 (100GE)\n* `100gbase-dr` - 100GBASE-DR (100GE)\n* `100gbase-er4` - 100GBASE-ER4 (100GE)\n* `100gbase-fr1` - 100GBASE-FR1 (100GE)\n* `100gbase-lr1` - 100GBASE-LR1 (100GE)\n* `100gbase-lr4` - 100GBASE-LR4 (100GE)\n* `100gbase-sr1` - 100GBASE-SR1 (100GE)\n* `100gbase-sr1.2` - 100GBASE-SR1.2 (100GE BiDi)\n* `100gbase-sr2` - 100GBASE-SR2 (100GE)\n* `100gbase-sr4` - 100GBASE-SR4 (100GE)\n* `100gbase-sr10` - 100GBASE-SR10 (100GE)\n* `100gbase-zr` - 100GBASE-ZR (100GE)\n* `200gbase-cr2` - 200GBASE-CR2 (200GE)\n* `200gbase-cr4` - 200GBASE-CR4 (200GE)\n* `200gbase-dr4` - 200GBASE-DR4 (200GE)\n* `200gbase-er4` - 200GBASE-ER4 (200GE)\n* `200gbase-fr4` - 200GBASE-FR4 (200GE)\n* `200gbase-lr4` - 200GBASE-LR4 (200GE)\n* `200gbase-sr2` - 200GBASE-SR2 (200GE)\n* `200gbase-sr4` - 200GBASE-SR4 (200GE)\n* `200gbase-vr2` - 200GBASE-VR2 (200GE)\n* `400gbase-cr4` - 400GBASE-CR4 (400GE)\n* `400gbase-dr4` - 400GBASE-DR4 (400GE)\n* `400gbase-er8` - 400GBASE-ER8 (400GE)\n* `400gbase-fr4` - 400GBASE-FR4 (400GE)\n* `400gbase-fr8` - 400GBASE-FR8 (400GE)\n* `400gbase-lr4` - 400GBASE-LR4 (400GE)\n* `400gbase-lr8` - 400GBASE-LR8 (400GE)\n* `400gbase-sr4` - 400GBASE-SR4 (400GE)\n* `400gbase-sr4_2` - 400GBASE-SR4.2 (400GE BiDi)\n* `400gbase-sr8` - 400GBASE-SR8 (400GE)\n* `400gbase-sr16` - 400GBASE-SR16 (400GE)\n* `400gbase-vr4` - 400GBASE-VR4 (400GE)\n* `400gbase-zr` - 400GBASE-ZR (400GE)\n* `800gbase-cr8` - 800GBASE-CR8 (800GE)\n* `800gbase-dr8` - 800GBASE-DR8 (800GE)\n* `800gbase-sr8` - 800GBASE-SR8 (800GE)\n* `800gbase-vr8` - 800GBASE-VR8 (800GE)\n* `1.6tbase-cr8` - 1.6TBASE-CR8 (1.6TE)\n* `1.6tbase-dr8` - 1.6TBASE-DR8 (1.6TE)\n* `1.6tbase-dr8-2` - 1.6TBASE-DR8-2 (1.6TE)\n* `100base-x-sfp` - SFP (100ME)\n* `1000base-x-gbic` - GBIC (1GE)\n* `1000base-x-sfp` - SFP (1GE)\n* `2.5gbase-x-sfp` - SFP (2.5GE)\n* `10gbase-x-sfpp` - SFP+ (10GE)\n* `10gbase-x-xenpak` - XENPAK (10GE)\n* `10gbase-x-xfp` - XFP (10GE)\n* `10gbase-x-x2` - X2 (10GE)\n* `25gbase-x-sfp28` - SFP28 (25GE)\n* `40gbase-x-qsfpp` - QSFP+ (40GE)\n* `50gbase-x-sfp28` - QSFP28 (50GE)\n* `50gbase-x-sfp56` - SFP56 (50GE)\n* `100gbase-x-cfp` - CFP (100GE)\n* `100gbase-x-cfp2` - CFP2 (100GE)\n* `100gbase-x-cfp4` - CFP4 (100GE)\n* `100gbase-x-cxp` - CXP (100GE)\n* `100gbase-x-cpak` - Cisco CPAK (100GE)\n* `100gbase-x-dsfp` - DSFP (100GE)\n* `100gbase-x-qsfp28` - QSFP28 (100GE)\n* `100gbase-x-qsfpdd` - QSFP-DD (100GE)\n* `100gbase-x-sfp112` - SFP112 (100GE)\n* `100gbase-x-sfpdd` - SFP-DD (100GE)\n* `200gbase-x-cfp2` - CFP2 (200GE)\n* `200gbase-x-qsfp56` - QSFP56 (200GE)\n* `200gbase-x-qsfpdd` - QSFP-DD (200GE)\n* `400gbase-x-qsfp112` - QSFP112 (400GE)\n* `400gbase-x-qsfpdd` - QSFP-DD (400GE)\n* `400gbase-x-cdfp` - CDFP (400GE)\n* `400gbase-x-cfp2` - CFP2 (400GE)\n* `400gbase-x-cfp8` - CPF8 (400GE)\n* `400gbase-x-osfp` - OSFP (400GE)\n* `400gbase-x-osfp-rhs` - OSFP-RHS (400GE)\n* `800gbase-x-osfp` - OSFP (800GE)\n* `800gbase-x-qsfpdd` - QSFP-DD (800GE)\n* `1.6tbase-x-osfp1600` - OSFP1600 (1.6TE)\n* `1.6tbase-x-osfp1600-rhs` - OSFP1600-RHS (1.6TE)\n* `1.6tbase-x-qsfpdd1600` - QSFP-DD1600 (1.6TE)\n* `1000base-kx` - 1000BASE-KX (1GE)\n* `2.5gbase-kx` - 2.5GBASE-KX (2.5GE)\n* `5gbase-kr` - 5GBASE-KR (5GE)\n* `10gbase-kr` - 10GBASE-KR (10GE)\n* `10gbase-kx4` - 10GBASE-KX4 (10GE)\n* `25gbase-kr` - 25GBASE-KR (25GE)\n* `40gbase-kr4` - 40GBASE-KR4 (40GE)\n* `50gbase-kr` - 50GBASE-KR (50GE)\n* `100gbase-kp4` - 100GBASE-KP4 (100GE)\n* `100gbase-kr2` - 100GBASE-KR2 (100GE)\n* `100gbase-kr4` - 100GBASE-KR4 (100GE)\n* `1.6tbase-kr8` - 1.6TBASE-KR8 (1.6TE)\n* `ieee802.11a` - IEEE 802.11a\n* `ieee802.11g` - IEEE 802.11b/g\n* `ieee802.11n` - IEEE 802.11n (Wi-Fi 4)\n* `ieee802.11ac` - IEEE 802.11ac (Wi-Fi 5)\n* `ieee802.11ad` - IEEE 802.11ad (WiGig)\n* `ieee802.11ax` - IEEE 802.11ax (Wi-Fi 6)\n* `ieee802.11ay` - IEEE 802.11ay (WiGig)\n* `ieee802.11be` - IEEE 802.11be (Wi-Fi 7)\n* `ieee802.15.1` - IEEE 802.15.1 (Bluetooth)\n* `ieee802.15.4` - IEEE 802.15.4 (LR-WPAN)\n* `other-wireless` - Other (Wireless)\n* `gsm` - GSM\n* `cdma` - CDMA\n* `lte` - LTE\n* `4g` - 4G\n* `5g` - 5G\n* `sonet-oc3` - OC-3/STM-1\n* `sonet-oc12` - OC-12/STM-4\n* `sonet-oc48` - OC-48/STM-16\n* `sonet-oc192` - OC-192/STM-64\n* `sonet-oc768` - OC-768/STM-256\n* `sonet-oc1920` - OC-1920/STM-640\n* `sonet-oc3840` - OC-3840/STM-1234\n* `1gfc-sfp` - SFP (1GFC)\n* `2gfc-sfp` - SFP (2GFC)\n* `4gfc-sfp` - SFP (4GFC)\n* `8gfc-sfpp` - SFP+ (8GFC)\n* `16gfc-sfpp` - SFP+ (16GFC)\n* `32gfc-sfp28` - SFP28 (32GFC)\n* `32gfc-sfpp` - SFP+ (32GFC)\n* `64gfc-qsfpp` - QSFP+ (64GFC)\n* `64gfc-sfpdd` - SFP-DD (64GFC)\n* `64gfc-sfpp` - SFP+ (64GFC)\n* `128gfc-qsfp28` - QSFP28 (128GFC)\n* `infiniband-sdr` - SDR (2 Gbps)\n* `infiniband-ddr` - DDR (4 Gbps)\n* `infiniband-qdr` - QDR (8 Gbps)\n* `infiniband-fdr10` - FDR10 (10 Gbps)\n* `infiniband-fdr` - FDR (13.5 Gbps)\n* `infiniband-edr` - EDR (25 Gbps)\n* `infiniband-hdr` - HDR (50 Gbps)\n* `infiniband-ndr` - NDR (100 Gbps)\n* `infiniband-xdr` - XDR (200 Gbps)\n* `infiniband-sdr-4x` - SDR 4X (8 Gbps)\n* `infiniband-ddr-4x` - DDR 4X (16 Gbps)\n* `infiniband-qdr-4x` - QDR 4X (32 Gbps)\n* `infiniband-fdr10-4x` - FDR10 4X (40 Gbps)\n* `infiniband-fdr-4x` - FDR 4X (56 Gbps)\n* `infiniband-edr-4x` - EDR 4X (100 Gbps)\n* `infiniband-hdr-4x` - HDR 4X (200 Gbps)\n* `infiniband-ndr-4x` - NDR 4X (400 Gbps)\n* `infiniband-xdr-4x` - XDR 4X (800 Gbps)\n* `t1` - T1 (1.544 Mbps)\n* `e1` - E1 (2.048 Mbps)\n* `t3` - T3 (45 Mbps)\n* `e3` - E3 (34 Mbps)\n* `xdsl` - xDSL\n* `docsis` - DOCSIS\n* `moca` - MoCA\n* `bpon` - BPON (622 Mbps / 155 Mbps)\n* `epon` - EPON (1 Gbps)\n* `10g-epon` - 10G-EPON (10 Gbps)\n* `gpon` - GPON (2.5 Gbps / 1.25 Gbps)\n* `xg-pon` - XG-PON (10 Gbps / 2.5 Gbps)\n* `xgs-pon` - XGS-PON (10 Gbps)\n* `ng-pon2` - NG-PON2 (TWDM-PON) (4x10 Gbps)\n* `25g-pon` - 25G-PON (25 Gbps)\n* `50g-pon` - 50G-PON (50 Gbps)\n* `cisco-stackwise` - Cisco StackWise\n* `cisco-stackwise-plus` - Cisco StackWise Plus\n* `cisco-flexstack` - Cisco FlexStack\n* `cisco-flexstack-plus` - Cisco FlexStack Plus\n* `cisco-stackwise-80` - Cisco StackWise-80\n* `cisco-stackwise-160` - Cisco StackWise-160\n* `cisco-stackwise-320` - Cisco StackWise-320\n* `cisco-stackwise-480` - Cisco StackWise-480\n* `cisco-stackwise-1t` - Cisco StackWise-1T\n* `juniper-vcp` - Juniper VCP\n* `extreme-summitstack` - Extreme SummitStack\n* `extreme-summitstack-128` - Extreme SummitStack-128\n* `extreme-summitstack-256` - Extreme SummitStack-256\n* `extreme-summitstack-512` - Extreme SummitStack-512\n* `hpe-synergy-interconnect-link` - HPE Synergy Interconnect Link\n* `other` - Other", + "x-spec-enum-id": "19cc901fcea417ad" + }, + "channels": { + "type": "integer", + "maximum": 1024, + "minimum": 1, + "nullable": true, + "description": "The number of channels into which this interface is channelized" + }, + "channel_id": { + "type": "integer", + "maximum": 1024, + "minimum": 1, + "nullable": true, + "description": "The channel on the parent interface to which this subinterface is bound" }, "enabled": { "type": "boolean" @@ -303260,6 +339142,11 @@ "type": "string", "maxLength": 200 }, + "parent": { + "type": "integer", + "nullable": true, + "title": "Parent interface" + }, "bridge": { "type": "integer", "nullable": true, @@ -303857,6 +339744,20 @@ "x-spec-enum-id": "5ad4e700c656b09d", "nullable": true }, + "cooling_method": { + "enum": [ + "air", + "liquid", + "hybrid", + "immersion", + "", + null + ], + "type": "string", + "description": "* `air` - Air\n* `liquid` - Liquid\n* `hybrid` - Hybrid\n* `immersion` - Immersion", + "x-spec-enum-id": "fff5375d415a4fdc", + "nullable": true + }, "weight": { "type": "number", "format": "double", @@ -303880,6 +339781,12 @@ "x-spec-enum-id": "2235ce3f404afbc0", "nullable": true }, + "end_of_life": { + "type": "string", + "format": "date", + "nullable": true, + "description": "The date after which this module type is no longer supported by the manufacturer" + }, "description": { "type": "string", "maxLength": 200 @@ -303887,6 +339794,12 @@ "attributes": { "nullable": true }, + "module_bay_types": { + "type": "array", + "items": { + "type": "integer" + } + }, "owner": { "oneOf": [ { @@ -305372,6 +341285,28 @@ "x-spec-enum-id": "a784734d07ef1b3c", "nullable": true }, + "cooling_capability": { + "enum": [ + "air-only", + "hybrid", + "liquid-only", + "", + null + ], + "type": "string", + "description": "* `air-only` - Air only\n* `hybrid` - Hybrid\n* `liquid-only` - Liquid only", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + }, + "cooling_capacity": { + "type": "number", + "format": "double", + "maximum": 100000000, + "minimum": 0, + "exclusiveMaximum": true, + "nullable": true, + "description": "Cooling capacity (kW)" + }, "description": { "type": "string", "maxLength": 200 @@ -305645,6 +341580,28 @@ "nullable": true, "description": "Maximum depth of a mounted device, in millimeters. For four-post racks, this is the distance between the front and rear rails." }, + "cooling_capability": { + "enum": [ + "air-only", + "hybrid", + "liquid-only", + "", + null + ], + "type": "string", + "description": "* `air-only` - Air only\n* `hybrid` - Hybrid\n* `liquid-only` - Liquid only", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + }, + "cooling_capacity": { + "type": "number", + "format": "double", + "maximum": 100000000, + "minimum": 0, + "exclusiveMaximum": true, + "nullable": true, + "description": "Cooling capacity (kW)" + }, "owner": { "oneOf": [ { @@ -306049,7 +342006,7 @@ }, "PatchedWritableServiceRequest": { "type": "object", - "description": "Base serializer class for models inheriting from PrimaryModel.", + "description": "Shared port-mapping handling for the Service and ServiceTemplate serializers, including backward\ncompatibility for the legacy single-protocol ``protocol``/``ports`` representation.\n\nRead: alongside the ``port_mappings`` list, a service that uses a single protocol also reports the\nlegacy ``protocol`` and ``ports`` fields; a multi-protocol service reports ``null`` for both (it\ncannot be expressed in the old single-protocol format).\n\nWrite: either format is accepted. When the legacy ``protocol``/``ports`` pair is supplied (and\n``port_mappings`` is not), it is translated into ``port_mappings``. Supplying both together is\naccepted only when the legacy fields agree with what ``port_mappings`` implies (e.g. a full-object\nround-trip that echoes back the read representation); a genuine conflict is rejected as ambiguous.\n\nSubclassing ``serializers.Serializer`` (rather than a plain mixin) lets DRF's metaclass collect the\nfields declared here into the inheriting serializers.", "properties": { "parent_object_type": { "type": "string" @@ -306065,15 +342022,24 @@ "minLength": 1, "maxLength": 100 }, + "port_mappings": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "protocol": { "enum": [ "tcp", "udp", - "sctp" + "sctp", + null ], "type": "string", - "description": "* `tcp` - TCP\n* `udp` - UDP\n* `sctp` - SCTP", - "x-spec-enum-id": "e4b15bec749a2a32" + "description": "Deprecated; use port_mappings. Reported only for single-protocol services.", + "x-spec-enum-id": "e4b15bec749a2a32", + "nullable": true }, "ports": { "type": "array", @@ -306082,7 +342048,8 @@ "maximum": 65535, "minimum": 1 }, - "title": "Port numbers" + "nullable": true, + "description": "Deprecated; use port_mappings. Reported only for single-protocol services." }, "ipaddresses": { "type": "array", @@ -306127,22 +342094,31 @@ }, "PatchedWritableServiceTemplateRequest": { "type": "object", - "description": "Base serializer class for models inheriting from PrimaryModel.", + "description": "Shared port-mapping handling for the Service and ServiceTemplate serializers, including backward\ncompatibility for the legacy single-protocol ``protocol``/``ports`` representation.\n\nRead: alongside the ``port_mappings`` list, a service that uses a single protocol also reports the\nlegacy ``protocol`` and ``ports`` fields; a multi-protocol service reports ``null`` for both (it\ncannot be expressed in the old single-protocol format).\n\nWrite: either format is accepted. When the legacy ``protocol``/``ports`` pair is supplied (and\n``port_mappings`` is not), it is translated into ``port_mappings``. Supplying both together is\naccepted only when the legacy fields agree with what ``port_mappings`` implies (e.g. a full-object\nround-trip that echoes back the read representation); a genuine conflict is rejected as ambiguous.\n\nSubclassing ``serializers.Serializer`` (rather than a plain mixin) lets DRF's metaclass collect the\nfields declared here into the inheriting serializers.", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 100 }, + "port_mappings": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "protocol": { "enum": [ "tcp", "udp", - "sctp" + "sctp", + null ], "type": "string", - "description": "* `tcp` - TCP\n* `udp` - UDP\n* `sctp` - SCTP", - "x-spec-enum-id": "e4b15bec749a2a32" + "description": "Deprecated; use port_mappings. Reported only for single-protocol services.", + "x-spec-enum-id": "e4b15bec749a2a32", + "nullable": true }, "ports": { "type": "array", @@ -306151,7 +342127,8 @@ "maximum": 65535, "minimum": 1 }, - "title": "Port numbers" + "nullable": true, + "description": "Deprecated; use port_mappings. Reported only for single-protocol services." }, "description": { "type": "string", @@ -306779,7 +342756,7 @@ }, "PatchedWritableVMInterfaceRequest": { "type": "object", - "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", + "description": "Mixin for Interface and VMInterface serializers that adds a `mac_address` shortcut field for\ncreating/updating the primary MACAddress in a single request. The validated write is centralized\non the interface model (BaseInterface.set_primary_mac_address); this mixin is a thin adapter that\nowns the permission check, the shortcut-vs-primary_mac_address conflict guard, and translating the\nmodel's validation errors into API errors.", "properties": { "virtual_machine": { "oneOf": [ @@ -306815,6 +342792,11 @@ "minimum": 1, "nullable": true }, + "mac_address": { + "type": "string", + "nullable": true, + "minLength": 1 + }, "primary_mac_address": { "oneOf": [ { @@ -307278,7 +343260,7 @@ } } }, - "PatchedWritableVirtualMachineWithConfigContextRequest": { + "PatchedWritableVirtualMachineRequest": { "type": "object", "description": "Base serializer class for models inheriting from PrimaryModel.", "properties": { @@ -312259,6 +348241,41 @@ } } }, + "cooling_capability": { + "type": "object", + "properties": { + "value": { + "enum": [ + "air-only", + "hybrid", + "liquid-only", + "", + null + ], + "type": "string", + "description": "* `air-only` - Air only\n* `hybrid` - Hybrid\n* `liquid-only` - Liquid only", + "x-spec-enum-id": "ebb8b6ef659d4888" + }, + "label": { + "type": "string", + "enum": [ + "Air only", + "Hybrid", + "Liquid only" + ] + } + }, + "nullable": true + }, + "cooling_capacity": { + "type": "number", + "format": "double", + "maximum": 100000000, + "minimum": 0, + "exclusiveMaximum": true, + "nullable": true, + "description": "Cooling capacity (kW)" + }, "description": { "type": "string", "maxLength": 200 @@ -312713,6 +348730,28 @@ "description": "* `front-to-rear` - Front to rear\n* `rear-to-front` - Rear to front", "x-spec-enum-id": "a784734d07ef1b3c" }, + "cooling_capability": { + "enum": [ + "air-only", + "hybrid", + "liquid-only", + "", + null + ], + "type": "string", + "description": "* `air-only` - Air only\n* `hybrid` - Hybrid\n* `liquid-only` - Liquid only", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + }, + "cooling_capacity": { + "type": "number", + "format": "double", + "maximum": 100000000, + "minimum": 0, + "exclusiveMaximum": true, + "nullable": true, + "description": "Cooling capacity (kW)" + }, "description": { "type": "string", "maxLength": 200 @@ -313338,6 +349377,41 @@ "nullable": true, "description": "Maximum depth of a mounted device, in millimeters. For four-post racks, this is the distance between the front and rear rails." }, + "cooling_capability": { + "type": "object", + "properties": { + "value": { + "enum": [ + "air-only", + "hybrid", + "liquid-only", + "", + null + ], + "type": "string", + "description": "* `air-only` - Air only\n* `hybrid` - Hybrid\n* `liquid-only` - Liquid only", + "x-spec-enum-id": "ebb8b6ef659d4888" + }, + "label": { + "type": "string", + "enum": [ + "Air only", + "Hybrid", + "Liquid only" + ] + } + }, + "nullable": true + }, + "cooling_capacity": { + "type": "number", + "format": "double", + "maximum": 100000000, + "minimum": 0, + "exclusiveMaximum": true, + "nullable": true, + "description": "Cooling capacity (kW)" + }, "owner": { "allOf": [ { @@ -313534,6 +349608,28 @@ "nullable": true, "description": "Maximum depth of a mounted device, in millimeters. For four-post racks, this is the distance between the front and rear rails." }, + "cooling_capability": { + "enum": [ + "air-only", + "hybrid", + "liquid-only", + "", + null + ], + "type": "string", + "description": "* `air-only` - Air only\n* `hybrid` - Hybrid\n* `liquid-only` - Liquid only", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + }, + "cooling_capacity": { + "type": "number", + "format": "double", + "maximum": 100000000, + "minimum": 0, + "exclusiveMaximum": true, + "nullable": true, + "description": "Cooling capacity (kW)" + }, "owner": { "oneOf": [ { @@ -315471,7 +351567,7 @@ }, "Service": { "type": "object", - "description": "Base serializer class for models inheriting from PrimaryModel.", + "description": "Shared port-mapping handling for the Service and ServiceTemplate serializers, including backward\ncompatibility for the legacy single-protocol ``protocol``/``ports`` representation.\n\nRead: alongside the ``port_mappings`` list, a service that uses a single protocol also reports the\nlegacy ``protocol`` and ``ports`` fields; a multi-protocol service reports ``null`` for both (it\ncannot be expressed in the old single-protocol format).\n\nWrite: either format is accepted. When the legacy ``protocol``/``ports`` pair is supplied (and\n``port_mappings`` is not), it is translated into ``port_mappings``. Supplying both together is\naccepted only when the legacy fields agree with what ``port_mappings`` implies (e.g. a full-object\nround-trip that echoes back the read representation); a genuine conflict is rejected as ambiguous.\n\nSubclassing ``serializers.Serializer`` (rather than a plain mixin) lets DRF's metaclass collect the\nfields declared here into the inheriting serializers.", "properties": { "id": { "type": "integer", @@ -315508,6 +351604,12 @@ "type": "string", "maxLength": 100 }, + "port_mappings": { + "type": "array", + "items": { + "type": "string" + } + }, "protocol": { "type": "object", "properties": { @@ -315515,7 +351617,8 @@ "enum": [ "tcp", "udp", - "sctp" + "sctp", + null ], "type": "string", "description": "* `tcp` - TCP\n* `udp` - UDP\n* `sctp` - SCTP", @@ -315529,7 +351632,9 @@ "SCTP" ] } - } + }, + "nullable": true, + "description": "Deprecated; use port_mappings. Reported only for single-protocol services." }, "ports": { "type": "array", @@ -315538,7 +351643,8 @@ "maximum": 65535, "minimum": 1 }, - "title": "Port numbers" + "nullable": true, + "description": "Deprecated; use port_mappings. Reported only for single-protocol services." }, "ipaddresses": { "type": "array", @@ -315594,13 +351700,12 @@ "parent", "parent_object_id", "parent_object_type", - "ports", "url" ] }, "ServiceRequest": { "type": "object", - "description": "Base serializer class for models inheriting from PrimaryModel.", + "description": "Shared port-mapping handling for the Service and ServiceTemplate serializers, including backward\ncompatibility for the legacy single-protocol ``protocol``/``ports`` representation.\n\nRead: alongside the ``port_mappings`` list, a service that uses a single protocol also reports the\nlegacy ``protocol`` and ``ports`` fields; a multi-protocol service reports ``null`` for both (it\ncannot be expressed in the old single-protocol format).\n\nWrite: either format is accepted. When the legacy ``protocol``/``ports`` pair is supplied (and\n``port_mappings`` is not), it is translated into ``port_mappings``. Supplying both together is\naccepted only when the legacy fields agree with what ``port_mappings`` implies (e.g. a full-object\nround-trip that echoes back the read representation); a genuine conflict is rejected as ambiguous.\n\nSubclassing ``serializers.Serializer`` (rather than a plain mixin) lets DRF's metaclass collect the\nfields declared here into the inheriting serializers.", "properties": { "parent_object_type": { "type": "string" @@ -315616,15 +351721,24 @@ "minLength": 1, "maxLength": 100 }, + "port_mappings": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "protocol": { "enum": [ "tcp", "udp", - "sctp" + "sctp", + null ], "type": "string", - "description": "* `tcp` - TCP\n* `udp` - UDP\n* `sctp` - SCTP", - "x-spec-enum-id": "e4b15bec749a2a32" + "description": "Deprecated; use port_mappings. Reported only for single-protocol services.", + "x-spec-enum-id": "e4b15bec749a2a32", + "nullable": true }, "ports": { "type": "array", @@ -315633,7 +351747,8 @@ "maximum": 65535, "minimum": 1 }, - "title": "Port numbers" + "nullable": true, + "description": "Deprecated; use port_mappings. Reported only for single-protocol services." }, "ipaddresses": { "type": "array", @@ -315678,13 +351793,12 @@ "required": [ "name", "parent_object_id", - "parent_object_type", - "ports" + "parent_object_type" ] }, "ServiceTemplate": { "type": "object", - "description": "Base serializer class for models inheriting from PrimaryModel.", + "description": "Shared port-mapping handling for the Service and ServiceTemplate serializers, including backward\ncompatibility for the legacy single-protocol ``protocol``/``ports`` representation.\n\nRead: alongside the ``port_mappings`` list, a service that uses a single protocol also reports the\nlegacy ``protocol`` and ``ports`` fields; a multi-protocol service reports ``null`` for both (it\ncannot be expressed in the old single-protocol format).\n\nWrite: either format is accepted. When the legacy ``protocol``/``ports`` pair is supplied (and\n``port_mappings`` is not), it is translated into ``port_mappings``. Supplying both together is\naccepted only when the legacy fields agree with what ``port_mappings`` implies (e.g. a full-object\nround-trip that echoes back the read representation); a genuine conflict is rejected as ambiguous.\n\nSubclassing ``serializers.Serializer`` (rather than a plain mixin) lets DRF's metaclass collect the\nfields declared here into the inheriting serializers.", "properties": { "id": { "type": "integer", @@ -315708,6 +351822,12 @@ "type": "string", "maxLength": 100 }, + "port_mappings": { + "type": "array", + "items": { + "type": "string" + } + }, "protocol": { "type": "object", "properties": { @@ -315715,7 +351835,8 @@ "enum": [ "tcp", "udp", - "sctp" + "sctp", + null ], "type": "string", "description": "* `tcp` - TCP\n* `udp` - UDP\n* `sctp` - SCTP", @@ -315729,7 +351850,9 @@ "SCTP" ] } - } + }, + "nullable": true, + "description": "Deprecated; use port_mappings. Reported only for single-protocol services." }, "ports": { "type": "array", @@ -315738,7 +351861,8 @@ "maximum": 65535, "minimum": 1 }, - "title": "Port numbers" + "nullable": true, + "description": "Deprecated; use port_mappings. Reported only for single-protocol services." }, "description": { "type": "string", @@ -315785,28 +351909,36 @@ "id", "last_updated", "name", - "ports", "url" ] }, "ServiceTemplateRequest": { "type": "object", - "description": "Base serializer class for models inheriting from PrimaryModel.", + "description": "Shared port-mapping handling for the Service and ServiceTemplate serializers, including backward\ncompatibility for the legacy single-protocol ``protocol``/``ports`` representation.\n\nRead: alongside the ``port_mappings`` list, a service that uses a single protocol also reports the\nlegacy ``protocol`` and ``ports`` fields; a multi-protocol service reports ``null`` for both (it\ncannot be expressed in the old single-protocol format).\n\nWrite: either format is accepted. When the legacy ``protocol``/``ports`` pair is supplied (and\n``port_mappings`` is not), it is translated into ``port_mappings``. Supplying both together is\naccepted only when the legacy fields agree with what ``port_mappings`` implies (e.g. a full-object\nround-trip that echoes back the read representation); a genuine conflict is rejected as ambiguous.\n\nSubclassing ``serializers.Serializer`` (rather than a plain mixin) lets DRF's metaclass collect the\nfields declared here into the inheriting serializers.", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 100 }, + "port_mappings": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "protocol": { "enum": [ "tcp", "udp", - "sctp" + "sctp", + null ], "type": "string", - "description": "* `tcp` - TCP\n* `udp` - UDP\n* `sctp` - SCTP", - "x-spec-enum-id": "e4b15bec749a2a32" + "description": "Deprecated; use port_mappings. Reported only for single-protocol services.", + "x-spec-enum-id": "e4b15bec749a2a32", + "nullable": true }, "ports": { "type": "array", @@ -315815,7 +351947,8 @@ "maximum": 65535, "minimum": 1 }, - "title": "Port numbers" + "nullable": true, + "description": "Deprecated; use port_mappings. Reported only for single-protocol services." }, "description": { "type": "string", @@ -315852,8 +351985,7 @@ } }, "required": [ - "name", - "ports" + "name" ] }, "Site": { @@ -317251,7 +353383,8 @@ "description": "ID of the cryptographic pepper used to hash the token (v2 only)" }, "token": { - "type": "string" + "type": "string", + "readOnly": true } }, "required": [ @@ -317260,6 +353393,7 @@ "display_url", "id", "key", + "token", "url", "user" ] @@ -317337,7 +353471,8 @@ "maxLength": 200 }, "token": { - "type": "string" + "type": "string", + "readOnly": true } }, "required": [ @@ -317347,6 +353482,7 @@ "id", "key", "last_used", + "token", "url", "user" ] @@ -317392,10 +353528,6 @@ "type": "string", "writeOnly": true, "minLength": 1 - }, - "token": { - "type": "string", - "minLength": 1 } }, "required": [ @@ -317456,10 +353588,6 @@ "minimum": 0, "nullable": true, "description": "ID of the cryptographic pepper used to hash the token (v2 only)" - }, - "token": { - "type": "string", - "minLength": 1 } }, "required": [ @@ -318943,7 +355071,7 @@ }, "VMInterface": { "type": "object", - "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", + "description": "Mixin for Interface and VMInterface serializers that adds a `mac_address` shortcut field for\ncreating/updating the primary MACAddress in a single request. The validated write is centralized\non the interface model (BaseInterface.set_primary_mac_address); this mixin is a thin adapter that\nowns the permission check, the shortcut-vs-primary_mac_address conflict guard, and translating the\nmodel's validation errors into API errors.", "properties": { "id": { "type": "integer", @@ -318997,7 +355125,6 @@ }, "mac_address": { "type": "string", - "readOnly": true, "nullable": true }, "primary_mac_address": { @@ -319141,7 +355268,6 @@ "id", "l2vpn_termination", "last_updated", - "mac_address", "mac_addresses", "name", "url", @@ -319150,7 +355276,7 @@ }, "VMInterfaceRequest": { "type": "object", - "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", + "description": "Mixin for Interface and VMInterface serializers that adds a `mac_address` shortcut field for\ncreating/updating the primary MACAddress in a single request. The validated write is centralized\non the interface model (BaseInterface.set_primary_mac_address); this mixin is a thin adapter that\nowns the permission check, the shortcut-vs-primary_mac_address conflict guard, and translating the\nmodel's validation errors into API errors.", "properties": { "virtual_machine": { "oneOf": [ @@ -319192,6 +355318,11 @@ "minimum": 1, "nullable": true }, + "mac_address": { + "type": "string", + "nullable": true, + "minLength": 1 + }, "primary_mac_address": { "oneOf": [ { @@ -320656,198 +356787,7 @@ "virtual_machine" ] }, - "VirtualMachineType": { - "type": "object", - "description": "Base serializer class for models inheriting from PrimaryModel.", - "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "url": { - "type": "string", - "format": "uri", - "readOnly": true - }, - "display_url": { - "type": "string", - "format": "uri", - "readOnly": true - }, - "display": { - "type": "string", - "readOnly": true - }, - "name": { - "type": "string", - "maxLength": 100 - }, - "slug": { - "type": "string", - "maxLength": 100, - "pattern": "^[-a-zA-Z0-9_]+$" - }, - "default_platform": { - "allOf": [ - { - "$ref": "#/components/schemas/BriefPlatform" - } - ], - "nullable": true - }, - "default_vcpus": { - "type": "number", - "format": "double", - "maximum": 10000, - "minimum": 0.01, - "exclusiveMaximum": true, - "nullable": true - }, - "default_memory": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0, - "nullable": true - }, - "description": { - "type": "string", - "maxLength": 200 - }, - "owner": { - "allOf": [ - { - "$ref": "#/components/schemas/BriefOwner" - } - ], - "nullable": true - }, - "comments": { - "type": "string" - }, - "tags": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NestedTag" - } - }, - "custom_fields": { - "type": "object", - "additionalProperties": {} - }, - "created": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "last_updated": { - "type": "string", - "format": "date-time", - "readOnly": true, - "nullable": true - }, - "virtual_machine_count": { - "type": "integer", - "readOnly": true - } - }, - "required": [ - "created", - "display", - "display_url", - "id", - "last_updated", - "name", - "slug", - "url", - "virtual_machine_count" - ] - }, - "VirtualMachineTypeRequest": { - "type": "object", - "description": "Base serializer class for models inheriting from PrimaryModel.", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100 - }, - "slug": { - "type": "string", - "minLength": 1, - "maxLength": 100, - "pattern": "^[-a-zA-Z0-9_]+$" - }, - "default_platform": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefPlatformRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "default_vcpus": { - "type": "number", - "format": "double", - "maximum": 10000, - "minimum": 0.01, - "exclusiveMaximum": true, - "nullable": true - }, - "default_memory": { - "type": "integer", - "maximum": 2147483647, - "minimum": 0, - "nullable": true - }, - "description": { - "type": "string", - "maxLength": 200 - }, - "owner": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefOwnerRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "comments": { - "type": "string" - }, - "tags": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NestedTagRequest" - } - }, - "custom_fields": { - "type": "object", - "additionalProperties": {} - } - }, - "required": [ - "name", - "slug" - ] - }, - "VirtualMachineWithConfigContext": { + "VirtualMachine": { "type": "object", "description": "Base serializer class for models inheriting from PrimaryModel.", "properties": { @@ -321109,7 +357049,7 @@ "virtual_disk_count" ] }, - "VirtualMachineWithConfigContextRequest": { + "VirtualMachineRequest": { "type": "object", "description": "Base serializer class for models inheriting from PrimaryModel.", "properties": { @@ -321369,6 +357309,197 @@ "name" ] }, + "VirtualMachineType": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "display_url": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "display": { + "type": "string", + "readOnly": true + }, + "name": { + "type": "string", + "maxLength": 100 + }, + "slug": { + "type": "string", + "maxLength": 100, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "default_platform": { + "allOf": [ + { + "$ref": "#/components/schemas/BriefPlatform" + } + ], + "nullable": true + }, + "default_vcpus": { + "type": "number", + "format": "double", + "maximum": 10000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "default_memory": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0, + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwner" + } + ], + "nullable": true + }, + "comments": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTag" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + }, + "created": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "last_updated": { + "type": "string", + "format": "date-time", + "readOnly": true, + "nullable": true + }, + "virtual_machine_count": { + "type": "integer", + "readOnly": true + } + }, + "required": [ + "created", + "display", + "display_url", + "id", + "last_updated", + "name", + "slug", + "url", + "virtual_machine_count" + ] + }, + "VirtualMachineTypeRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "default_platform": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefPlatformRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "default_vcpus": { + "type": "number", + "format": "double", + "maximum": 10000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "default_memory": { + "type": "integer", + "maximum": 2147483647, + "minimum": 0, + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "comments": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "name", + "slug" + ] + }, "Webhook": { "type": "object", "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", @@ -321428,7 +357559,7 @@ }, "body_template": { "type": "string", - "description": "Jinja2 template for a custom request body. If blank, a JSON object representing the change will be included. Available context data includes: event, model, timestamp, username, request_id, and data." + "description": "Jinja2 template for a custom request body. If blank, a JSON object representing the change will be included. Available context data includes: event, model, timestamp, request, and data." }, "secret": { "type": "string", @@ -321445,6 +357576,13 @@ "description": "The specific CA certificate file to use for SSL verification. Leave blank to use the system defaults.", "maxLength": 4096 }, + "timeout": { + "type": "integer", + "maximum": 3600, + "minimum": 1, + "nullable": true, + "description": "The maximum time (in seconds) to wait for a response before failing the request. Leave blank to use the system default (60 seconds)." + }, "custom_fields": { "type": "object", "additionalProperties": {} @@ -321531,7 +357669,7 @@ }, "body_template": { "type": "string", - "description": "Jinja2 template for a custom request body. If blank, a JSON object representing the change will be included. Available context data includes: event, model, timestamp, username, request_id, and data." + "description": "Jinja2 template for a custom request body. If blank, a JSON object representing the change will be included. Available context data includes: event, model, timestamp, request, and data." }, "secret": { "type": "string", @@ -321548,6 +357686,13 @@ "description": "The specific CA certificate file to use for SSL verification. Leave blank to use the system defaults.", "maxLength": 4096 }, + "timeout": { + "type": "integer", + "maximum": 3600, + "minimum": 1, + "nullable": true, + "description": "The maximum time (in seconds) to wait for a response before failing the request. Leave blank to use the system default (60 seconds)." + }, "custom_fields": { "type": "object", "additionalProperties": {} @@ -323578,6 +359723,753 @@ "slug" ] }, + "WritableCoolingFeedRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "cooling_source": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefCoolingSourceRequest" + } + ] + }, + "rack": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefRackRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "status": { + "enum": [ + "offline", + "active", + "planned", + "failed" + ], + "type": "string", + "description": "* `offline` - Offline\n* `active` - Active\n* `planned` - Planned\n* `failed` - Failed", + "x-spec-enum-id": "ec530572dc778583" + }, + "cooling_capacity": { + "type": "number", + "format": "double", + "maximum": 100000000, + "minimum": 0, + "exclusiveMaximum": true, + "nullable": true, + "description": "Rated cooling capacity (kW)" + }, + "max_flow": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "max_flow_unit": { + "enum": [ + "lpm", + "m3ph", + "gpm", + "", + null + ], + "type": "string", + "description": "* `lpm` - Liters per minute (L/min)\n* `m3ph` - Cubic meters per hour (m³/h)\n* `gpm` - Gallons per minute (GPM)", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "tenant": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefTenantRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "comments": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "cooling_source", + "name" + ] + }, + "WritableCoolingIntakeRequest": { + "type": "object", + "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", + "properties": { + "device": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefDeviceRequest" + } + ] + }, + "module": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefModuleRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "label": { + "type": "string", + "description": "Physical label", + "maxLength": 64 + }, + "type": { + "enum": [ + "uqd", + "uqdb", + "qdc", + "camlock", + "npt", + "bsp", + "proprietary", + "", + null + ], + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true, + "description": "Physical connector type\n\n* `uqd` - UQD (Universal Quick Disconnect)\n* `uqdb` - UQDB (Universal Quick Disconnect, Blind-mate)\n* `qdc` - QDC (Quick Disconnect Coupling)\n* `camlock` - Camlock (cam-and-groove)\n* `npt` - NPT (threaded)\n* `bsp` - BSP (threaded)\n* `proprietary` - Proprietary" + }, + "diameter": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "diameter_unit": { + "enum": [ + "mm", + "cm", + "in", + "", + null + ], + "type": "string", + "description": "* `mm` - Millimeters\n* `cm` - Centimeters\n* `in` - Inches", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + }, + "max_flow": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "max_flow_unit": { + "enum": [ + "lpm", + "m3ph", + "gpm", + "", + null + ], + "type": "string", + "description": "* `lpm` - Liters per minute (L/min)\n* `m3ph` - Cubic meters per hour (m³/h)\n* `gpm` - Gallons per minute (GPM)", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + }, + "cooling_outflow": { + "type": "integer", + "nullable": true, + "description": "The upstream cooling outflow supplying this intake" + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "device", + "name" + ] + }, + "WritableCoolingIntakeTemplateRequest": { + "type": "object", + "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)", + "properties": { + "device_type": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefDeviceTypeRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "module_type": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefModuleTypeRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "description": "{module} is accepted as a substitution for the module bay position when attached to a module type.", + "maxLength": 64 + }, + "label": { + "type": "string", + "description": "Physical label", + "maxLength": 64 + }, + "type": { + "enum": [ + "uqd", + "uqdb", + "qdc", + "camlock", + "npt", + "bsp", + "proprietary", + "", + null + ], + "type": "string", + "description": "* `uqd` - UQD (Universal Quick Disconnect)\n* `uqdb` - UQDB (Universal Quick Disconnect, Blind-mate)\n* `qdc` - QDC (Quick Disconnect Coupling)\n* `camlock` - Camlock (cam-and-groove)\n* `npt` - NPT (threaded)\n* `bsp` - BSP (threaded)\n* `proprietary` - Proprietary", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + }, + "diameter": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "diameter_unit": { + "enum": [ + "mm", + "cm", + "in", + "", + null + ], + "type": "string", + "description": "* `mm` - Millimeters\n* `cm` - Centimeters\n* `in` - Inches", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + }, + "max_flow": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "max_flow_unit": { + "enum": [ + "lpm", + "m3ph", + "gpm", + "", + null + ], + "type": "string", + "description": "* `lpm` - Liters per minute (L/min)\n* `m3ph` - Cubic meters per hour (m³/h)\n* `gpm` - Gallons per minute (GPM)", + "x-spec-enum-id": "fe1b41d88d34506a", + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + } + }, + "required": [ + "name" + ] + }, + "WritableCoolingOutflowRequest": { + "type": "object", + "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", + "properties": { + "device": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefDeviceRequest" + } + ] + }, + "module": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefModuleRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "label": { + "type": "string", + "description": "Physical label", + "maxLength": 64 + }, + "type": { + "enum": [ + "uqd", + "uqdb", + "qdc", + "camlock", + "npt", + "bsp", + "proprietary", + "", + null + ], + "type": "string", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true, + "description": "Physical connector type\n\n* `uqd` - UQD (Universal Quick Disconnect)\n* `uqdb` - UQDB (Universal Quick Disconnect, Blind-mate)\n* `qdc` - QDC (Quick Disconnect Coupling)\n* `camlock` - Camlock (cam-and-groove)\n* `npt` - NPT (threaded)\n* `bsp` - BSP (threaded)\n* `proprietary` - Proprietary" + }, + "diameter": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "diameter_unit": { + "enum": [ + "mm", + "cm", + "in", + "", + null + ], + "type": "string", + "description": "* `mm` - Millimeters\n* `cm` - Centimeters\n* `in` - Inches", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + }, + "cooling_intake": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefCoolingIntakeRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "device", + "name" + ] + }, + "WritableCoolingOutflowTemplateRequest": { + "type": "object", + "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)", + "properties": { + "device_type": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefDeviceTypeRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "module_type": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefModuleTypeRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "description": "{module} is accepted as a substitution for the module bay position when attached to a module type.", + "maxLength": 64 + }, + "label": { + "type": "string", + "description": "Physical label", + "maxLength": 64 + }, + "type": { + "enum": [ + "uqd", + "uqdb", + "qdc", + "camlock", + "npt", + "bsp", + "proprietary", + "", + null + ], + "type": "string", + "description": "* `uqd` - UQD (Universal Quick Disconnect)\n* `uqdb` - UQDB (Universal Quick Disconnect, Blind-mate)\n* `qdc` - QDC (Quick Disconnect Coupling)\n* `camlock` - Camlock (cam-and-groove)\n* `npt` - NPT (threaded)\n* `bsp` - BSP (threaded)\n* `proprietary` - Proprietary", + "x-spec-enum-id": "90159a314e98688d", + "nullable": true + }, + "diameter": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": 0.01, + "exclusiveMaximum": true, + "nullable": true + }, + "diameter_unit": { + "enum": [ + "mm", + "cm", + "in", + "", + null + ], + "type": "string", + "description": "* `mm` - Millimeters\n* `cm` - Centimeters\n* `in` - Inches", + "x-spec-enum-id": "2eee9703528be3dd", + "nullable": true + }, + "cooling_intake": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefCoolingIntakeTemplateRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + } + }, + "required": [ + "name" + ] + }, + "WritableCoolingSourceRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "site": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefSiteRequest" + } + ] + }, + "location": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefLocationRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "type": { + "enum": [ + "chiller", + "cooling-tower", + "dry-cooler", + "crac", + "crah" + ], + "type": "string", + "description": "* `chiller` - Chiller\n* `cooling-tower` - Cooling tower\n* `dry-cooler` - Dry cooler\n* `crac` - CRAC\n* `crah` - CRAH", + "x-spec-enum-id": "f225f830b0d77ac5" + }, + "status": { + "enum": [ + "offline", + "active", + "planned", + "failed" + ], + "type": "string", + "description": "* `offline` - Offline\n* `active` - Active\n* `planned` - Planned\n* `failed` - Failed", + "x-spec-enum-id": "ec530572dc778583" + }, + "fluid_type": { + "enum": [ + "water", + "water-glycol", + "dielectric", + "refrigerant", + "", + null + ], + "type": "string", + "description": "* `water` - Water\n* `water-glycol` - Water/glycol\n* `dielectric` - Dielectric\n* `refrigerant` - Refrigerant", + "x-spec-enum-id": "b558e2a3f7349765", + "nullable": true + }, + "cooling_capacity": { + "type": "number", + "format": "double", + "maximum": 100000000, + "minimum": 0, + "exclusiveMaximum": true, + "nullable": true, + "description": "Total rated cooling capacity (kW)" + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "comments": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "name", + "site", + "type" + ] + }, "WritableCustomFieldChoiceSetRequest": { "type": "object", "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", @@ -323689,8 +360581,8 @@ "multiobject" ], "type": "string", - "x-spec-enum-id": "47c52a3d983e924c", - "description": "The type of data this custom field holds\n\n* `text` - Text\n* `longtext` - Text (long)\n* `integer` - Integer\n* `decimal` - Decimal\n* `boolean` - Boolean (true/false)\n* `date` - Date\n* `datetime` - Date & time\n* `url` - URL\n* `json` - JSON\n* `select` - Selection\n* `multiselect` - Multiple selection\n* `object` - Object\n* `multiobject` - Multiple objects" + "x-spec-enum-id": "6ec6eff91d34cc44", + "description": "The type of data this custom field holds\n\n* `text` - Text\n* `longtext` - Text (long)\n* `integer` - Integer\n* `decimal` - Decimal\n* `boolean` - Boolean\n* `date` - Date\n* `datetime` - Date & time\n* `url` - URL\n* `json` - JSON\n* `select` - Selection\n* `multiselect` - Multiple selection\n* `object` - Object\n* `multiobject` - Multiple objects" }, "related_object_type": { "type": "string", @@ -323766,6 +360658,10 @@ "type": "boolean", "description": "Replicate this value when cloning objects" }, + "nulls_first": { + "type": "boolean", + "description": "Sort null values before non-null values when ordering by this field" + }, "default": { "nullable": true, "description": "Default value for the field (must be a JSON value). Encapsulate strings with double quotes (e.g. \"Foo\")." @@ -323934,260 +360830,7 @@ "type" ] }, - "WritableDeviceRoleRequest": { - "type": "object", - "description": "Base serializer class for models inheriting from NestedGroupModel.", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100 - }, - "slug": { - "type": "string", - "minLength": 1, - "maxLength": 100, - "pattern": "^[-a-zA-Z0-9_]+$" - }, - "color": { - "type": "string", - "minLength": 1, - "pattern": "^[0-9a-f]{6}$", - "maxLength": 6 - }, - "vm_role": { - "type": "boolean", - "description": "Virtual machines may be assigned to this role" - }, - "config_template": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefConfigTemplateRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "parent": { - "type": "integer", - "nullable": true - }, - "description": { - "type": "string", - "maxLength": 200 - }, - "tags": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NestedTagRequest" - } - }, - "custom_fields": { - "type": "object", - "additionalProperties": {} - }, - "owner": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefOwnerRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "comments": { - "type": "string" - } - }, - "required": [ - "name", - "slug" - ] - }, - "WritableDeviceTypeRequest": { - "type": "object", - "description": "Base serializer class for models inheriting from PrimaryModel.", - "properties": { - "manufacturer": { - "oneOf": [ - { - "type": "integer" - }, - { - "$ref": "#/components/schemas/BriefManufacturerRequest" - } - ] - }, - "default_platform": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefPlatformRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "model": { - "type": "string", - "minLength": 1, - "maxLength": 100 - }, - "slug": { - "type": "string", - "minLength": 1, - "maxLength": 100, - "pattern": "^[-a-zA-Z0-9_]+$" - }, - "part_number": { - "type": "string", - "description": "Discrete part number (optional)", - "maxLength": 50 - }, - "u_height": { - "type": "number", - "format": "double", - "maximum": 1000, - "minimum": 0.0, - "exclusiveMaximum": true, - "default": 1.0, - "title": "Position (U)" - }, - "exclude_from_utilization": { - "type": "boolean", - "description": "Devices of this type are excluded when calculating rack utilization." - }, - "is_full_depth": { - "type": "boolean", - "description": "Device consumes both front and rear rack faces." - }, - "subdevice_role": { - "enum": [ - "parent", - "child", - "", - null - ], - "type": "string", - "x-spec-enum-id": "65a61d5e1deb4a24", - "nullable": true, - "title": "Parent/child status", - "description": "Parent devices house child devices in device bays. Leave blank if this device type is neither a parent nor a child.\n\n* `parent` - Parent\n* `child` - Child" - }, - "airflow": { - "enum": [ - "front-to-rear", - "rear-to-front", - "left-to-right", - "right-to-left", - "side-to-rear", - "rear-to-side", - "bottom-to-top", - "top-to-bottom", - "passive", - "mixed", - "", - null - ], - "type": "string", - "description": "* `front-to-rear` - Front to rear\n* `rear-to-front` - Rear to front\n* `left-to-right` - Left to right\n* `right-to-left` - Right to left\n* `side-to-rear` - Side to rear\n* `rear-to-side` - Rear to side\n* `bottom-to-top` - Bottom to top\n* `top-to-bottom` - Top to bottom\n* `passive` - Passive\n* `mixed` - Mixed", - "x-spec-enum-id": "11cb3d363b41ba9e", - "nullable": true - }, - "weight": { - "type": "number", - "format": "double", - "maximum": 1000000, - "minimum": -1000000, - "exclusiveMaximum": true, - "exclusiveMinimum": true, - "nullable": true - }, - "weight_unit": { - "enum": [ - "kg", - "g", - "lb", - "oz", - "", - null - ], - "type": "string", - "description": "* `kg` - Kilograms\n* `g` - Grams\n* `lb` - Pounds\n* `oz` - Ounces", - "x-spec-enum-id": "2235ce3f404afbc0", - "nullable": true - }, - "front_image": { - "type": "string", - "format": "binary", - "nullable": true - }, - "rear_image": { - "type": "string", - "format": "binary", - "nullable": true - }, - "description": { - "type": "string", - "maxLength": 200 - }, - "owner": { - "oneOf": [ - { - "type": "integer" - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/BriefOwnerRequest" - } - ], - "nullable": true - } - ], - "nullable": true - }, - "comments": { - "type": "string" - }, - "tags": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NestedTagRequest" - } - }, - "custom_fields": { - "type": "object", - "additionalProperties": {} - } - }, - "required": [ - "manufacturer", - "model", - "slug" - ] - }, - "WritableDeviceWithConfigContextRequest": { + "WritableDeviceRequest": { "type": "object", "description": "Base serializer class for models inheriting from PrimaryModel.", "properties": { @@ -324374,6 +361017,20 @@ "x-spec-enum-id": "11cb3d363b41ba9e", "nullable": true }, + "cooling_method": { + "enum": [ + "air", + "liquid", + "hybrid", + "immersion", + "", + null + ], + "type": "string", + "description": "* `air` - Air\n* `liquid` - Liquid\n* `hybrid` - Hybrid\n* `immersion` - Immersion", + "x-spec-enum-id": "fff5375d415a4fdc", + "nullable": true + }, "primary_ip4": { "oneOf": [ { @@ -324527,6 +361184,279 @@ "site" ] }, + "WritableDeviceRoleRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from NestedGroupModel.", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "color": { + "type": "string", + "minLength": 1, + "pattern": "^[0-9a-f]{6}$", + "maxLength": 6 + }, + "vm_role": { + "type": "boolean", + "description": "Virtual machines may be assigned to this role" + }, + "config_template": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefConfigTemplateRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "parent": { + "type": "integer", + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "comments": { + "type": "string" + } + }, + "required": [ + "name", + "slug" + ] + }, + "WritableDeviceTypeRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "manufacturer": { + "oneOf": [ + { + "type": "integer" + }, + { + "$ref": "#/components/schemas/BriefManufacturerRequest" + } + ] + }, + "default_platform": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefPlatformRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "part_number": { + "type": "string", + "description": "Discrete part number (optional)", + "maxLength": 50 + }, + "u_height": { + "type": "number", + "format": "double", + "maximum": 1000, + "minimum": 0.0, + "exclusiveMaximum": true, + "default": 1.0, + "title": "Position (U)" + }, + "exclude_from_utilization": { + "type": "boolean", + "description": "Devices of this type are excluded when calculating rack utilization." + }, + "is_full_depth": { + "type": "boolean", + "description": "Device consumes both front and rear rack faces." + }, + "subdevice_role": { + "enum": [ + "parent", + "child", + "", + null + ], + "type": "string", + "x-spec-enum-id": "65a61d5e1deb4a24", + "nullable": true, + "title": "Parent/child status", + "description": "Parent devices house child devices in device bays. Leave blank if this device type is neither a parent nor a child.\n\n* `parent` - Parent\n* `child` - Child" + }, + "airflow": { + "enum": [ + "front-to-rear", + "rear-to-front", + "left-to-right", + "right-to-left", + "side-to-rear", + "rear-to-side", + "bottom-to-top", + "top-to-bottom", + "passive", + "mixed", + "", + null + ], + "type": "string", + "description": "* `front-to-rear` - Front to rear\n* `rear-to-front` - Rear to front\n* `left-to-right` - Left to right\n* `right-to-left` - Right to left\n* `side-to-rear` - Side to rear\n* `rear-to-side` - Rear to side\n* `bottom-to-top` - Bottom to top\n* `top-to-bottom` - Top to bottom\n* `passive` - Passive\n* `mixed` - Mixed", + "x-spec-enum-id": "11cb3d363b41ba9e", + "nullable": true + }, + "cooling_method": { + "enum": [ + "air", + "liquid", + "hybrid", + "immersion", + "", + null + ], + "type": "string", + "description": "* `air` - Air\n* `liquid` - Liquid\n* `hybrid` - Hybrid\n* `immersion` - Immersion", + "x-spec-enum-id": "fff5375d415a4fdc", + "nullable": true + }, + "weight": { + "type": "number", + "format": "double", + "maximum": 1000000, + "minimum": -1000000, + "exclusiveMaximum": true, + "exclusiveMinimum": true, + "nullable": true + }, + "weight_unit": { + "enum": [ + "kg", + "g", + "lb", + "oz", + "", + null + ], + "type": "string", + "description": "* `kg` - Kilograms\n* `g` - Grams\n* `lb` - Pounds\n* `oz` - Ounces", + "x-spec-enum-id": "2235ce3f404afbc0", + "nullable": true + }, + "end_of_life": { + "type": "string", + "format": "date", + "nullable": true, + "description": "The date after which this device type is no longer supported by the manufacturer" + }, + "front_image": { + "type": "string", + "format": "binary", + "nullable": true + }, + "rear_image": { + "type": "string", + "format": "binary", + "nullable": true + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "owner": { + "oneOf": [ + { + "type": "integer" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/BriefOwnerRequest" + } + ], + "nullable": true + } + ], + "nullable": true + }, + "comments": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NestedTagRequest" + } + }, + "custom_fields": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "manufacturer", + "model", + "slug" + ] + }, "WritableEventRuleRequest": { "type": "object", "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", @@ -324578,7 +361508,8 @@ "x-spec-enum-id": "287901b937995956" }, "action_object_type": { - "type": "string" + "type": "string", + "nullable": true }, "action_object_id": { "type": "integer", @@ -324619,7 +361550,7 @@ } }, "required": [ - "action_object_type", + "action_type", "event_types", "name", "object_types" @@ -325685,7 +362616,7 @@ }, "WritableInterfaceRequest": { "type": "object", - "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", + "description": "Mixin for Interface and VMInterface serializers that adds a `mac_address` shortcut field for\ncreating/updating the primary MACAddress in a single request. The validated write is centralized\non the interface model (BaseInterface.set_primary_mac_address); this mixin is a thin adapter that\nowns the permission check, the shortcut-vs-primary_mac_address conflict guard, and translating the\nmodel's validation errors into API errors.", "properties": { "device": { "oneOf": [ @@ -325734,6 +362665,7 @@ "virtual", "bridge", "lag", + "channel", "100base-fx", "100base-lfx", "100base-tx", @@ -325960,8 +362892,22 @@ "other" ], "type": "string", - "description": "* `virtual` - Virtual\n* `bridge` - Bridge\n* `lag` - Link Aggregation Group (LAG)\n* `100base-fx` - 100BASE-FX (10/100ME)\n* `100base-lfx` - 100BASE-LFX (10/100ME)\n* `100base-tx` - 100BASE-TX (10/100ME)\n* `100base-t1` - 100BASE-T1 (10/100ME)\n* `1000base-bx10-d` - 1000BASE-BX10-D (1GE BiDi Down)\n* `1000base-bx10-u` - 1000BASE-BX10-U (1GE BiDi Up)\n* `1000base-cwdm` - 1000BASE-CWDM (1GE)\n* `1000base-cx` - 1000BASE-CX (1GE DAC)\n* `1000base-dwdm` - 1000BASE-DWDM (1GE)\n* `1000base-ex` - 1000BASE-EX (1GE)\n* `1000base-lsx` - 1000BASE-LSX (1GE)\n* `1000base-lx` - 1000BASE-LX (1GE)\n* `1000base-lx10` - 1000BASE-LX10/LH (1GE)\n* `1000base-sx` - 1000BASE-SX (1GE)\n* `1000base-t` - 1000BASE-T (1GE)\n* `1000base-tx` - 1000BASE-TX (1GE)\n* `1000base-zx` - 1000BASE-ZX (1GE)\n* `2.5gbase-t` - 2.5GBASE-T (2.5GE)\n* `5gbase-t` - 5GBASE-T (5GE)\n* `10gbase-br-d` - 10GBASE-BR-D (10GE BiDi Down)\n* `10gbase-br-u` - 10GBASE-BR-U (10GE BiDi Up)\n* `10gbase-cu` - 10GBASE-CU (10GE DAC Passive Twinax)\n* `10gbase-cx4` - 10GBASE-CX4 (10GE DAC)\n* `10gbase-er` - 10GBASE-ER (10GE)\n* `10gbase-lr` - 10GBASE-LR (10GE)\n* `10gbase-lrm` - 10GBASE-LRM (10GE)\n* `10gbase-lx4` - 10GBASE-LX4 (10GE)\n* `10gbase-sr` - 10GBASE-SR (10GE)\n* `10gbase-t` - 10GBASE-T (10GE)\n* `10gbase-zr` - 10GBASE-ZR (10GE)\n* `25gbase-cr` - 25GBASE-CR (25GE DAC)\n* `25gbase-er` - 25GBASE-ER (25GE)\n* `25gbase-lr` - 25GBASE-LR (25GE)\n* `25gbase-sr` - 25GBASE-SR (25GE)\n* `25gbase-t` - 25GBASE-T (25GE)\n* `40gbase-cr4` - 40GBASE-CR4 (40GE DAC)\n* `40gbase-er4` - 40GBASE-ER4 (40GE)\n* `40gbase-fr4` - 40GBASE-FR4 (40GE)\n* `40gbase-lr4` - 40GBASE-LR4 (40GE)\n* `40gbase-sr4` - 40GBASE-SR4 (40GE)\n* `40gbase-sr4-bd` - 40GBASE-SR4 (40GE BiDi)\n* `50gbase-cr` - 50GBASE-CR (50GE DAC)\n* `50gbase-er` - 50GBASE-ER (50GE)\n* `50gbase-fr` - 50GBASE-FR (50GE)\n* `50gbase-lr` - 50GBASE-LR (50GE)\n* `50gbase-sr` - 50GBASE-SR (50GE)\n* `100gbase-cr1` - 100GBASE-CR1 (100GE DAC)\n* `100gbase-cr2` - 100GBASE-CR2 (100GE DAC)\n* `100gbase-cr4` - 100GBASE-CR4 (100GE DAC)\n* `100gbase-cr10` - 100GBASE-CR10 (100GE DAC)\n* `100gbase-cwdm4` - 100GBASE-CWDM4 (100GE)\n* `100gbase-dr` - 100GBASE-DR (100GE)\n* `100gbase-er4` - 100GBASE-ER4 (100GE)\n* `100gbase-fr1` - 100GBASE-FR1 (100GE)\n* `100gbase-lr1` - 100GBASE-LR1 (100GE)\n* `100gbase-lr4` - 100GBASE-LR4 (100GE)\n* `100gbase-sr1` - 100GBASE-SR1 (100GE)\n* `100gbase-sr1.2` - 100GBASE-SR1.2 (100GE BiDi)\n* `100gbase-sr2` - 100GBASE-SR2 (100GE)\n* `100gbase-sr4` - 100GBASE-SR4 (100GE)\n* `100gbase-sr10` - 100GBASE-SR10 (100GE)\n* `100gbase-zr` - 100GBASE-ZR (100GE)\n* `200gbase-cr2` - 200GBASE-CR2 (200GE)\n* `200gbase-cr4` - 200GBASE-CR4 (200GE)\n* `200gbase-dr4` - 200GBASE-DR4 (200GE)\n* `200gbase-er4` - 200GBASE-ER4 (200GE)\n* `200gbase-fr4` - 200GBASE-FR4 (200GE)\n* `200gbase-lr4` - 200GBASE-LR4 (200GE)\n* `200gbase-sr2` - 200GBASE-SR2 (200GE)\n* `200gbase-sr4` - 200GBASE-SR4 (200GE)\n* `200gbase-vr2` - 200GBASE-VR2 (200GE)\n* `400gbase-cr4` - 400GBASE-CR4 (400GE)\n* `400gbase-dr4` - 400GBASE-DR4 (400GE)\n* `400gbase-er8` - 400GBASE-ER8 (400GE)\n* `400gbase-fr4` - 400GBASE-FR4 (400GE)\n* `400gbase-fr8` - 400GBASE-FR8 (400GE)\n* `400gbase-lr4` - 400GBASE-LR4 (400GE)\n* `400gbase-lr8` - 400GBASE-LR8 (400GE)\n* `400gbase-sr4` - 400GBASE-SR4 (400GE)\n* `400gbase-sr4_2` - 400GBASE-SR4.2 (400GE BiDi)\n* `400gbase-sr8` - 400GBASE-SR8 (400GE)\n* `400gbase-sr16` - 400GBASE-SR16 (400GE)\n* `400gbase-vr4` - 400GBASE-VR4 (400GE)\n* `400gbase-zr` - 400GBASE-ZR (400GE)\n* `800gbase-cr8` - 800GBASE-CR8 (800GE)\n* `800gbase-dr8` - 800GBASE-DR8 (800GE)\n* `800gbase-sr8` - 800GBASE-SR8 (800GE)\n* `800gbase-vr8` - 800GBASE-VR8 (800GE)\n* `1.6tbase-cr8` - 1.6TBASE-CR8 (1.6TE)\n* `1.6tbase-dr8` - 1.6TBASE-DR8 (1.6TE)\n* `1.6tbase-dr8-2` - 1.6TBASE-DR8-2 (1.6TE)\n* `100base-x-sfp` - SFP (100ME)\n* `1000base-x-gbic` - GBIC (1GE)\n* `1000base-x-sfp` - SFP (1GE)\n* `2.5gbase-x-sfp` - SFP (2.5GE)\n* `10gbase-x-sfpp` - SFP+ (10GE)\n* `10gbase-x-xenpak` - XENPAK (10GE)\n* `10gbase-x-xfp` - XFP (10GE)\n* `10gbase-x-x2` - X2 (10GE)\n* `25gbase-x-sfp28` - SFP28 (25GE)\n* `40gbase-x-qsfpp` - QSFP+ (40GE)\n* `50gbase-x-sfp28` - QSFP28 (50GE)\n* `50gbase-x-sfp56` - SFP56 (50GE)\n* `100gbase-x-cfp` - CFP (100GE)\n* `100gbase-x-cfp2` - CFP2 (100GE)\n* `100gbase-x-cfp4` - CFP4 (100GE)\n* `100gbase-x-cxp` - CXP (100GE)\n* `100gbase-x-cpak` - Cisco CPAK (100GE)\n* `100gbase-x-dsfp` - DSFP (100GE)\n* `100gbase-x-qsfp28` - QSFP28 (100GE)\n* `100gbase-x-qsfpdd` - QSFP-DD (100GE)\n* `100gbase-x-sfp112` - SFP112 (100GE)\n* `100gbase-x-sfpdd` - SFP-DD (100GE)\n* `200gbase-x-cfp2` - CFP2 (200GE)\n* `200gbase-x-qsfp56` - QSFP56 (200GE)\n* `200gbase-x-qsfpdd` - QSFP-DD (200GE)\n* `400gbase-x-qsfp112` - QSFP112 (400GE)\n* `400gbase-x-qsfpdd` - QSFP-DD (400GE)\n* `400gbase-x-cdfp` - CDFP (400GE)\n* `400gbase-x-cfp2` - CFP2 (400GE)\n* `400gbase-x-cfp8` - CPF8 (400GE)\n* `400gbase-x-osfp` - OSFP (400GE)\n* `400gbase-x-osfp-rhs` - OSFP-RHS (400GE)\n* `800gbase-x-osfp` - OSFP (800GE)\n* `800gbase-x-qsfpdd` - QSFP-DD (800GE)\n* `1.6tbase-x-osfp1600` - OSFP1600 (1.6TE)\n* `1.6tbase-x-osfp1600-rhs` - OSFP1600-RHS (1.6TE)\n* `1.6tbase-x-qsfpdd1600` - QSFP-DD1600 (1.6TE)\n* `1000base-kx` - 1000BASE-KX (1GE)\n* `2.5gbase-kx` - 2.5GBASE-KX (2.5GE)\n* `5gbase-kr` - 5GBASE-KR (5GE)\n* `10gbase-kr` - 10GBASE-KR (10GE)\n* `10gbase-kx4` - 10GBASE-KX4 (10GE)\n* `25gbase-kr` - 25GBASE-KR (25GE)\n* `40gbase-kr4` - 40GBASE-KR4 (40GE)\n* `50gbase-kr` - 50GBASE-KR (50GE)\n* `100gbase-kp4` - 100GBASE-KP4 (100GE)\n* `100gbase-kr2` - 100GBASE-KR2 (100GE)\n* `100gbase-kr4` - 100GBASE-KR4 (100GE)\n* `1.6tbase-kr8` - 1.6TBASE-KR8 (1.6TE)\n* `ieee802.11a` - IEEE 802.11a\n* `ieee802.11g` - IEEE 802.11b/g\n* `ieee802.11n` - IEEE 802.11n (Wi-Fi 4)\n* `ieee802.11ac` - IEEE 802.11ac (Wi-Fi 5)\n* `ieee802.11ad` - IEEE 802.11ad (WiGig)\n* `ieee802.11ax` - IEEE 802.11ax (Wi-Fi 6)\n* `ieee802.11ay` - IEEE 802.11ay (WiGig)\n* `ieee802.11be` - IEEE 802.11be (Wi-Fi 7)\n* `ieee802.15.1` - IEEE 802.15.1 (Bluetooth)\n* `ieee802.15.4` - IEEE 802.15.4 (LR-WPAN)\n* `other-wireless` - Other (Wireless)\n* `gsm` - GSM\n* `cdma` - CDMA\n* `lte` - LTE\n* `4g` - 4G\n* `5g` - 5G\n* `sonet-oc3` - OC-3/STM-1\n* `sonet-oc12` - OC-12/STM-4\n* `sonet-oc48` - OC-48/STM-16\n* `sonet-oc192` - OC-192/STM-64\n* `sonet-oc768` - OC-768/STM-256\n* `sonet-oc1920` - OC-1920/STM-640\n* `sonet-oc3840` - OC-3840/STM-1234\n* `1gfc-sfp` - SFP (1GFC)\n* `2gfc-sfp` - SFP (2GFC)\n* `4gfc-sfp` - SFP (4GFC)\n* `8gfc-sfpp` - SFP+ (8GFC)\n* `16gfc-sfpp` - SFP+ (16GFC)\n* `32gfc-sfp28` - SFP28 (32GFC)\n* `32gfc-sfpp` - SFP+ (32GFC)\n* `64gfc-qsfpp` - QSFP+ (64GFC)\n* `64gfc-sfpdd` - SFP-DD (64GFC)\n* `64gfc-sfpp` - SFP+ (64GFC)\n* `128gfc-qsfp28` - QSFP28 (128GFC)\n* `infiniband-sdr` - SDR (2 Gbps)\n* `infiniband-ddr` - DDR (4 Gbps)\n* `infiniband-qdr` - QDR (8 Gbps)\n* `infiniband-fdr10` - FDR10 (10 Gbps)\n* `infiniband-fdr` - FDR (13.5 Gbps)\n* `infiniband-edr` - EDR (25 Gbps)\n* `infiniband-hdr` - HDR (50 Gbps)\n* `infiniband-ndr` - NDR (100 Gbps)\n* `infiniband-xdr` - XDR (200 Gbps)\n* `infiniband-sdr-4x` - SDR 4X (8 Gbps)\n* `infiniband-ddr-4x` - DDR 4X (16 Gbps)\n* `infiniband-qdr-4x` - QDR 4X (32 Gbps)\n* `infiniband-fdr10-4x` - FDR10 4X (40 Gbps)\n* `infiniband-fdr-4x` - FDR 4X (56 Gbps)\n* `infiniband-edr-4x` - EDR 4X (100 Gbps)\n* `infiniband-hdr-4x` - HDR 4X (200 Gbps)\n* `infiniband-ndr-4x` - NDR 4X (400 Gbps)\n* `infiniband-xdr-4x` - XDR 4X (800 Gbps)\n* `t1` - T1 (1.544 Mbps)\n* `e1` - E1 (2.048 Mbps)\n* `t3` - T3 (45 Mbps)\n* `e3` - E3 (34 Mbps)\n* `xdsl` - xDSL\n* `docsis` - DOCSIS\n* `moca` - MoCA\n* `bpon` - BPON (622 Mbps / 155 Mbps)\n* `epon` - EPON (1 Gbps)\n* `10g-epon` - 10G-EPON (10 Gbps)\n* `gpon` - GPON (2.5 Gbps / 1.25 Gbps)\n* `xg-pon` - XG-PON (10 Gbps / 2.5 Gbps)\n* `xgs-pon` - XGS-PON (10 Gbps)\n* `ng-pon2` - NG-PON2 (TWDM-PON) (4x10 Gbps)\n* `25g-pon` - 25G-PON (25 Gbps)\n* `50g-pon` - 50G-PON (50 Gbps)\n* `cisco-stackwise` - Cisco StackWise\n* `cisco-stackwise-plus` - Cisco StackWise Plus\n* `cisco-flexstack` - Cisco FlexStack\n* `cisco-flexstack-plus` - Cisco FlexStack Plus\n* `cisco-stackwise-80` - Cisco StackWise-80\n* `cisco-stackwise-160` - Cisco StackWise-160\n* `cisco-stackwise-320` - Cisco StackWise-320\n* `cisco-stackwise-480` - Cisco StackWise-480\n* `cisco-stackwise-1t` - Cisco StackWise-1T\n* `juniper-vcp` - Juniper VCP\n* `extreme-summitstack` - Extreme SummitStack\n* `extreme-summitstack-128` - Extreme SummitStack-128\n* `extreme-summitstack-256` - Extreme SummitStack-256\n* `extreme-summitstack-512` - Extreme SummitStack-512\n* `hpe-synergy-interconnect-link` - HPE Synergy Interconnect Link\n* `other` - Other", - "x-spec-enum-id": "b0c97040e5abdff1" + "description": "* `virtual` - Virtual\n* `bridge` - Bridge\n* `lag` - Link Aggregation Group (LAG)\n* `channel` - Channel\n* `100base-fx` - 100BASE-FX (10/100ME)\n* `100base-lfx` - 100BASE-LFX (10/100ME)\n* `100base-tx` - 100BASE-TX (10/100ME)\n* `100base-t1` - 100BASE-T1 (10/100ME)\n* `1000base-bx10-d` - 1000BASE-BX10-D (1GE BiDi Down)\n* `1000base-bx10-u` - 1000BASE-BX10-U (1GE BiDi Up)\n* `1000base-cwdm` - 1000BASE-CWDM (1GE)\n* `1000base-cx` - 1000BASE-CX (1GE DAC)\n* `1000base-dwdm` - 1000BASE-DWDM (1GE)\n* `1000base-ex` - 1000BASE-EX (1GE)\n* `1000base-lsx` - 1000BASE-LSX (1GE)\n* `1000base-lx` - 1000BASE-LX (1GE)\n* `1000base-lx10` - 1000BASE-LX10/LH (1GE)\n* `1000base-sx` - 1000BASE-SX (1GE)\n* `1000base-t` - 1000BASE-T (1GE)\n* `1000base-tx` - 1000BASE-TX (1GE)\n* `1000base-zx` - 1000BASE-ZX (1GE)\n* `2.5gbase-t` - 2.5GBASE-T (2.5GE)\n* `5gbase-t` - 5GBASE-T (5GE)\n* `10gbase-br-d` - 10GBASE-BR-D (10GE BiDi Down)\n* `10gbase-br-u` - 10GBASE-BR-U (10GE BiDi Up)\n* `10gbase-cu` - 10GBASE-CU (10GE DAC Passive Twinax)\n* `10gbase-cx4` - 10GBASE-CX4 (10GE DAC)\n* `10gbase-er` - 10GBASE-ER (10GE)\n* `10gbase-lr` - 10GBASE-LR (10GE)\n* `10gbase-lrm` - 10GBASE-LRM (10GE)\n* `10gbase-lx4` - 10GBASE-LX4 (10GE)\n* `10gbase-sr` - 10GBASE-SR (10GE)\n* `10gbase-t` - 10GBASE-T (10GE)\n* `10gbase-zr` - 10GBASE-ZR (10GE)\n* `25gbase-cr` - 25GBASE-CR (25GE DAC)\n* `25gbase-er` - 25GBASE-ER (25GE)\n* `25gbase-lr` - 25GBASE-LR (25GE)\n* `25gbase-sr` - 25GBASE-SR (25GE)\n* `25gbase-t` - 25GBASE-T (25GE)\n* `40gbase-cr4` - 40GBASE-CR4 (40GE DAC)\n* `40gbase-er4` - 40GBASE-ER4 (40GE)\n* `40gbase-fr4` - 40GBASE-FR4 (40GE)\n* `40gbase-lr4` - 40GBASE-LR4 (40GE)\n* `40gbase-sr4` - 40GBASE-SR4 (40GE)\n* `40gbase-sr4-bd` - 40GBASE-SR4 (40GE BiDi)\n* `50gbase-cr` - 50GBASE-CR (50GE DAC)\n* `50gbase-er` - 50GBASE-ER (50GE)\n* `50gbase-fr` - 50GBASE-FR (50GE)\n* `50gbase-lr` - 50GBASE-LR (50GE)\n* `50gbase-sr` - 50GBASE-SR (50GE)\n* `100gbase-cr1` - 100GBASE-CR1 (100GE DAC)\n* `100gbase-cr2` - 100GBASE-CR2 (100GE DAC)\n* `100gbase-cr4` - 100GBASE-CR4 (100GE DAC)\n* `100gbase-cr10` - 100GBASE-CR10 (100GE DAC)\n* `100gbase-cwdm4` - 100GBASE-CWDM4 (100GE)\n* `100gbase-dr` - 100GBASE-DR (100GE)\n* `100gbase-er4` - 100GBASE-ER4 (100GE)\n* `100gbase-fr1` - 100GBASE-FR1 (100GE)\n* `100gbase-lr1` - 100GBASE-LR1 (100GE)\n* `100gbase-lr4` - 100GBASE-LR4 (100GE)\n* `100gbase-sr1` - 100GBASE-SR1 (100GE)\n* `100gbase-sr1.2` - 100GBASE-SR1.2 (100GE BiDi)\n* `100gbase-sr2` - 100GBASE-SR2 (100GE)\n* `100gbase-sr4` - 100GBASE-SR4 (100GE)\n* `100gbase-sr10` - 100GBASE-SR10 (100GE)\n* `100gbase-zr` - 100GBASE-ZR (100GE)\n* `200gbase-cr2` - 200GBASE-CR2 (200GE)\n* `200gbase-cr4` - 200GBASE-CR4 (200GE)\n* `200gbase-dr4` - 200GBASE-DR4 (200GE)\n* `200gbase-er4` - 200GBASE-ER4 (200GE)\n* `200gbase-fr4` - 200GBASE-FR4 (200GE)\n* `200gbase-lr4` - 200GBASE-LR4 (200GE)\n* `200gbase-sr2` - 200GBASE-SR2 (200GE)\n* `200gbase-sr4` - 200GBASE-SR4 (200GE)\n* `200gbase-vr2` - 200GBASE-VR2 (200GE)\n* `400gbase-cr4` - 400GBASE-CR4 (400GE)\n* `400gbase-dr4` - 400GBASE-DR4 (400GE)\n* `400gbase-er8` - 400GBASE-ER8 (400GE)\n* `400gbase-fr4` - 400GBASE-FR4 (400GE)\n* `400gbase-fr8` - 400GBASE-FR8 (400GE)\n* `400gbase-lr4` - 400GBASE-LR4 (400GE)\n* `400gbase-lr8` - 400GBASE-LR8 (400GE)\n* `400gbase-sr4` - 400GBASE-SR4 (400GE)\n* `400gbase-sr4_2` - 400GBASE-SR4.2 (400GE BiDi)\n* `400gbase-sr8` - 400GBASE-SR8 (400GE)\n* `400gbase-sr16` - 400GBASE-SR16 (400GE)\n* `400gbase-vr4` - 400GBASE-VR4 (400GE)\n* `400gbase-zr` - 400GBASE-ZR (400GE)\n* `800gbase-cr8` - 800GBASE-CR8 (800GE)\n* `800gbase-dr8` - 800GBASE-DR8 (800GE)\n* `800gbase-sr8` - 800GBASE-SR8 (800GE)\n* `800gbase-vr8` - 800GBASE-VR8 (800GE)\n* `1.6tbase-cr8` - 1.6TBASE-CR8 (1.6TE)\n* `1.6tbase-dr8` - 1.6TBASE-DR8 (1.6TE)\n* `1.6tbase-dr8-2` - 1.6TBASE-DR8-2 (1.6TE)\n* `100base-x-sfp` - SFP (100ME)\n* `1000base-x-gbic` - GBIC (1GE)\n* `1000base-x-sfp` - SFP (1GE)\n* `2.5gbase-x-sfp` - SFP (2.5GE)\n* `10gbase-x-sfpp` - SFP+ (10GE)\n* `10gbase-x-xenpak` - XENPAK (10GE)\n* `10gbase-x-xfp` - XFP (10GE)\n* `10gbase-x-x2` - X2 (10GE)\n* `25gbase-x-sfp28` - SFP28 (25GE)\n* `40gbase-x-qsfpp` - QSFP+ (40GE)\n* `50gbase-x-sfp28` - QSFP28 (50GE)\n* `50gbase-x-sfp56` - SFP56 (50GE)\n* `100gbase-x-cfp` - CFP (100GE)\n* `100gbase-x-cfp2` - CFP2 (100GE)\n* `100gbase-x-cfp4` - CFP4 (100GE)\n* `100gbase-x-cxp` - CXP (100GE)\n* `100gbase-x-cpak` - Cisco CPAK (100GE)\n* `100gbase-x-dsfp` - DSFP (100GE)\n* `100gbase-x-qsfp28` - QSFP28 (100GE)\n* `100gbase-x-qsfpdd` - QSFP-DD (100GE)\n* `100gbase-x-sfp112` - SFP112 (100GE)\n* `100gbase-x-sfpdd` - SFP-DD (100GE)\n* `200gbase-x-cfp2` - CFP2 (200GE)\n* `200gbase-x-qsfp56` - QSFP56 (200GE)\n* `200gbase-x-qsfpdd` - QSFP-DD (200GE)\n* `400gbase-x-qsfp112` - QSFP112 (400GE)\n* `400gbase-x-qsfpdd` - QSFP-DD (400GE)\n* `400gbase-x-cdfp` - CDFP (400GE)\n* `400gbase-x-cfp2` - CFP2 (400GE)\n* `400gbase-x-cfp8` - CPF8 (400GE)\n* `400gbase-x-osfp` - OSFP (400GE)\n* `400gbase-x-osfp-rhs` - OSFP-RHS (400GE)\n* `800gbase-x-osfp` - OSFP (800GE)\n* `800gbase-x-qsfpdd` - QSFP-DD (800GE)\n* `1.6tbase-x-osfp1600` - OSFP1600 (1.6TE)\n* `1.6tbase-x-osfp1600-rhs` - OSFP1600-RHS (1.6TE)\n* `1.6tbase-x-qsfpdd1600` - QSFP-DD1600 (1.6TE)\n* `1000base-kx` - 1000BASE-KX (1GE)\n* `2.5gbase-kx` - 2.5GBASE-KX (2.5GE)\n* `5gbase-kr` - 5GBASE-KR (5GE)\n* `10gbase-kr` - 10GBASE-KR (10GE)\n* `10gbase-kx4` - 10GBASE-KX4 (10GE)\n* `25gbase-kr` - 25GBASE-KR (25GE)\n* `40gbase-kr4` - 40GBASE-KR4 (40GE)\n* `50gbase-kr` - 50GBASE-KR (50GE)\n* `100gbase-kp4` - 100GBASE-KP4 (100GE)\n* `100gbase-kr2` - 100GBASE-KR2 (100GE)\n* `100gbase-kr4` - 100GBASE-KR4 (100GE)\n* `1.6tbase-kr8` - 1.6TBASE-KR8 (1.6TE)\n* `ieee802.11a` - IEEE 802.11a\n* `ieee802.11g` - IEEE 802.11b/g\n* `ieee802.11n` - IEEE 802.11n (Wi-Fi 4)\n* `ieee802.11ac` - IEEE 802.11ac (Wi-Fi 5)\n* `ieee802.11ad` - IEEE 802.11ad (WiGig)\n* `ieee802.11ax` - IEEE 802.11ax (Wi-Fi 6)\n* `ieee802.11ay` - IEEE 802.11ay (WiGig)\n* `ieee802.11be` - IEEE 802.11be (Wi-Fi 7)\n* `ieee802.15.1` - IEEE 802.15.1 (Bluetooth)\n* `ieee802.15.4` - IEEE 802.15.4 (LR-WPAN)\n* `other-wireless` - Other (Wireless)\n* `gsm` - GSM\n* `cdma` - CDMA\n* `lte` - LTE\n* `4g` - 4G\n* `5g` - 5G\n* `sonet-oc3` - OC-3/STM-1\n* `sonet-oc12` - OC-12/STM-4\n* `sonet-oc48` - OC-48/STM-16\n* `sonet-oc192` - OC-192/STM-64\n* `sonet-oc768` - OC-768/STM-256\n* `sonet-oc1920` - OC-1920/STM-640\n* `sonet-oc3840` - OC-3840/STM-1234\n* `1gfc-sfp` - SFP (1GFC)\n* `2gfc-sfp` - SFP (2GFC)\n* `4gfc-sfp` - SFP (4GFC)\n* `8gfc-sfpp` - SFP+ (8GFC)\n* `16gfc-sfpp` - SFP+ (16GFC)\n* `32gfc-sfp28` - SFP28 (32GFC)\n* `32gfc-sfpp` - SFP+ (32GFC)\n* `64gfc-qsfpp` - QSFP+ (64GFC)\n* `64gfc-sfpdd` - SFP-DD (64GFC)\n* `64gfc-sfpp` - SFP+ (64GFC)\n* `128gfc-qsfp28` - QSFP28 (128GFC)\n* `infiniband-sdr` - SDR (2 Gbps)\n* `infiniband-ddr` - DDR (4 Gbps)\n* `infiniband-qdr` - QDR (8 Gbps)\n* `infiniband-fdr10` - FDR10 (10 Gbps)\n* `infiniband-fdr` - FDR (13.5 Gbps)\n* `infiniband-edr` - EDR (25 Gbps)\n* `infiniband-hdr` - HDR (50 Gbps)\n* `infiniband-ndr` - NDR (100 Gbps)\n* `infiniband-xdr` - XDR (200 Gbps)\n* `infiniband-sdr-4x` - SDR 4X (8 Gbps)\n* `infiniband-ddr-4x` - DDR 4X (16 Gbps)\n* `infiniband-qdr-4x` - QDR 4X (32 Gbps)\n* `infiniband-fdr10-4x` - FDR10 4X (40 Gbps)\n* `infiniband-fdr-4x` - FDR 4X (56 Gbps)\n* `infiniband-edr-4x` - EDR 4X (100 Gbps)\n* `infiniband-hdr-4x` - HDR 4X (200 Gbps)\n* `infiniband-ndr-4x` - NDR 4X (400 Gbps)\n* `infiniband-xdr-4x` - XDR 4X (800 Gbps)\n* `t1` - T1 (1.544 Mbps)\n* `e1` - E1 (2.048 Mbps)\n* `t3` - T3 (45 Mbps)\n* `e3` - E3 (34 Mbps)\n* `xdsl` - xDSL\n* `docsis` - DOCSIS\n* `moca` - MoCA\n* `bpon` - BPON (622 Mbps / 155 Mbps)\n* `epon` - EPON (1 Gbps)\n* `10g-epon` - 10G-EPON (10 Gbps)\n* `gpon` - GPON (2.5 Gbps / 1.25 Gbps)\n* `xg-pon` - XG-PON (10 Gbps / 2.5 Gbps)\n* `xgs-pon` - XGS-PON (10 Gbps)\n* `ng-pon2` - NG-PON2 (TWDM-PON) (4x10 Gbps)\n* `25g-pon` - 25G-PON (25 Gbps)\n* `50g-pon` - 50G-PON (50 Gbps)\n* `cisco-stackwise` - Cisco StackWise\n* `cisco-stackwise-plus` - Cisco StackWise Plus\n* `cisco-flexstack` - Cisco FlexStack\n* `cisco-flexstack-plus` - Cisco FlexStack Plus\n* `cisco-stackwise-80` - Cisco StackWise-80\n* `cisco-stackwise-160` - Cisco StackWise-160\n* `cisco-stackwise-320` - Cisco StackWise-320\n* `cisco-stackwise-480` - Cisco StackWise-480\n* `cisco-stackwise-1t` - Cisco StackWise-1T\n* `juniper-vcp` - Juniper VCP\n* `extreme-summitstack` - Extreme SummitStack\n* `extreme-summitstack-128` - Extreme SummitStack-128\n* `extreme-summitstack-256` - Extreme SummitStack-256\n* `extreme-summitstack-512` - Extreme SummitStack-512\n* `hpe-synergy-interconnect-link` - HPE Synergy Interconnect Link\n* `other` - Other", + "x-spec-enum-id": "19cc901fcea417ad" + }, + "channels": { + "type": "integer", + "maximum": 1024, + "minimum": 1, + "nullable": true, + "description": "The number of channels into which this interface is channelized" + }, + "channel_id": { + "type": "integer", + "maximum": 1024, + "minimum": 1, + "nullable": true, + "description": "The channel on the parent interface to which this subinterface is bound" }, "enabled": { "type": "boolean" @@ -325987,6 +362933,11 @@ "minimum": 1, "nullable": true }, + "mac_address": { + "type": "string", + "nullable": true, + "minLength": 1 + }, "primary_mac_address": { "oneOf": [ { @@ -326496,6 +363447,7 @@ "virtual", "bridge", "lag", + "channel", "100base-fx", "100base-lfx", "100base-tx", @@ -326722,8 +363674,22 @@ "other" ], "type": "string", - "description": "* `virtual` - Virtual\n* `bridge` - Bridge\n* `lag` - Link Aggregation Group (LAG)\n* `100base-fx` - 100BASE-FX (10/100ME)\n* `100base-lfx` - 100BASE-LFX (10/100ME)\n* `100base-tx` - 100BASE-TX (10/100ME)\n* `100base-t1` - 100BASE-T1 (10/100ME)\n* `1000base-bx10-d` - 1000BASE-BX10-D (1GE BiDi Down)\n* `1000base-bx10-u` - 1000BASE-BX10-U (1GE BiDi Up)\n* `1000base-cwdm` - 1000BASE-CWDM (1GE)\n* `1000base-cx` - 1000BASE-CX (1GE DAC)\n* `1000base-dwdm` - 1000BASE-DWDM (1GE)\n* `1000base-ex` - 1000BASE-EX (1GE)\n* `1000base-lsx` - 1000BASE-LSX (1GE)\n* `1000base-lx` - 1000BASE-LX (1GE)\n* `1000base-lx10` - 1000BASE-LX10/LH (1GE)\n* `1000base-sx` - 1000BASE-SX (1GE)\n* `1000base-t` - 1000BASE-T (1GE)\n* `1000base-tx` - 1000BASE-TX (1GE)\n* `1000base-zx` - 1000BASE-ZX (1GE)\n* `2.5gbase-t` - 2.5GBASE-T (2.5GE)\n* `5gbase-t` - 5GBASE-T (5GE)\n* `10gbase-br-d` - 10GBASE-BR-D (10GE BiDi Down)\n* `10gbase-br-u` - 10GBASE-BR-U (10GE BiDi Up)\n* `10gbase-cu` - 10GBASE-CU (10GE DAC Passive Twinax)\n* `10gbase-cx4` - 10GBASE-CX4 (10GE DAC)\n* `10gbase-er` - 10GBASE-ER (10GE)\n* `10gbase-lr` - 10GBASE-LR (10GE)\n* `10gbase-lrm` - 10GBASE-LRM (10GE)\n* `10gbase-lx4` - 10GBASE-LX4 (10GE)\n* `10gbase-sr` - 10GBASE-SR (10GE)\n* `10gbase-t` - 10GBASE-T (10GE)\n* `10gbase-zr` - 10GBASE-ZR (10GE)\n* `25gbase-cr` - 25GBASE-CR (25GE DAC)\n* `25gbase-er` - 25GBASE-ER (25GE)\n* `25gbase-lr` - 25GBASE-LR (25GE)\n* `25gbase-sr` - 25GBASE-SR (25GE)\n* `25gbase-t` - 25GBASE-T (25GE)\n* `40gbase-cr4` - 40GBASE-CR4 (40GE DAC)\n* `40gbase-er4` - 40GBASE-ER4 (40GE)\n* `40gbase-fr4` - 40GBASE-FR4 (40GE)\n* `40gbase-lr4` - 40GBASE-LR4 (40GE)\n* `40gbase-sr4` - 40GBASE-SR4 (40GE)\n* `40gbase-sr4-bd` - 40GBASE-SR4 (40GE BiDi)\n* `50gbase-cr` - 50GBASE-CR (50GE DAC)\n* `50gbase-er` - 50GBASE-ER (50GE)\n* `50gbase-fr` - 50GBASE-FR (50GE)\n* `50gbase-lr` - 50GBASE-LR (50GE)\n* `50gbase-sr` - 50GBASE-SR (50GE)\n* `100gbase-cr1` - 100GBASE-CR1 (100GE DAC)\n* `100gbase-cr2` - 100GBASE-CR2 (100GE DAC)\n* `100gbase-cr4` - 100GBASE-CR4 (100GE DAC)\n* `100gbase-cr10` - 100GBASE-CR10 (100GE DAC)\n* `100gbase-cwdm4` - 100GBASE-CWDM4 (100GE)\n* `100gbase-dr` - 100GBASE-DR (100GE)\n* `100gbase-er4` - 100GBASE-ER4 (100GE)\n* `100gbase-fr1` - 100GBASE-FR1 (100GE)\n* `100gbase-lr1` - 100GBASE-LR1 (100GE)\n* `100gbase-lr4` - 100GBASE-LR4 (100GE)\n* `100gbase-sr1` - 100GBASE-SR1 (100GE)\n* `100gbase-sr1.2` - 100GBASE-SR1.2 (100GE BiDi)\n* `100gbase-sr2` - 100GBASE-SR2 (100GE)\n* `100gbase-sr4` - 100GBASE-SR4 (100GE)\n* `100gbase-sr10` - 100GBASE-SR10 (100GE)\n* `100gbase-zr` - 100GBASE-ZR (100GE)\n* `200gbase-cr2` - 200GBASE-CR2 (200GE)\n* `200gbase-cr4` - 200GBASE-CR4 (200GE)\n* `200gbase-dr4` - 200GBASE-DR4 (200GE)\n* `200gbase-er4` - 200GBASE-ER4 (200GE)\n* `200gbase-fr4` - 200GBASE-FR4 (200GE)\n* `200gbase-lr4` - 200GBASE-LR4 (200GE)\n* `200gbase-sr2` - 200GBASE-SR2 (200GE)\n* `200gbase-sr4` - 200GBASE-SR4 (200GE)\n* `200gbase-vr2` - 200GBASE-VR2 (200GE)\n* `400gbase-cr4` - 400GBASE-CR4 (400GE)\n* `400gbase-dr4` - 400GBASE-DR4 (400GE)\n* `400gbase-er8` - 400GBASE-ER8 (400GE)\n* `400gbase-fr4` - 400GBASE-FR4 (400GE)\n* `400gbase-fr8` - 400GBASE-FR8 (400GE)\n* `400gbase-lr4` - 400GBASE-LR4 (400GE)\n* `400gbase-lr8` - 400GBASE-LR8 (400GE)\n* `400gbase-sr4` - 400GBASE-SR4 (400GE)\n* `400gbase-sr4_2` - 400GBASE-SR4.2 (400GE BiDi)\n* `400gbase-sr8` - 400GBASE-SR8 (400GE)\n* `400gbase-sr16` - 400GBASE-SR16 (400GE)\n* `400gbase-vr4` - 400GBASE-VR4 (400GE)\n* `400gbase-zr` - 400GBASE-ZR (400GE)\n* `800gbase-cr8` - 800GBASE-CR8 (800GE)\n* `800gbase-dr8` - 800GBASE-DR8 (800GE)\n* `800gbase-sr8` - 800GBASE-SR8 (800GE)\n* `800gbase-vr8` - 800GBASE-VR8 (800GE)\n* `1.6tbase-cr8` - 1.6TBASE-CR8 (1.6TE)\n* `1.6tbase-dr8` - 1.6TBASE-DR8 (1.6TE)\n* `1.6tbase-dr8-2` - 1.6TBASE-DR8-2 (1.6TE)\n* `100base-x-sfp` - SFP (100ME)\n* `1000base-x-gbic` - GBIC (1GE)\n* `1000base-x-sfp` - SFP (1GE)\n* `2.5gbase-x-sfp` - SFP (2.5GE)\n* `10gbase-x-sfpp` - SFP+ (10GE)\n* `10gbase-x-xenpak` - XENPAK (10GE)\n* `10gbase-x-xfp` - XFP (10GE)\n* `10gbase-x-x2` - X2 (10GE)\n* `25gbase-x-sfp28` - SFP28 (25GE)\n* `40gbase-x-qsfpp` - QSFP+ (40GE)\n* `50gbase-x-sfp28` - QSFP28 (50GE)\n* `50gbase-x-sfp56` - SFP56 (50GE)\n* `100gbase-x-cfp` - CFP (100GE)\n* `100gbase-x-cfp2` - CFP2 (100GE)\n* `100gbase-x-cfp4` - CFP4 (100GE)\n* `100gbase-x-cxp` - CXP (100GE)\n* `100gbase-x-cpak` - Cisco CPAK (100GE)\n* `100gbase-x-dsfp` - DSFP (100GE)\n* `100gbase-x-qsfp28` - QSFP28 (100GE)\n* `100gbase-x-qsfpdd` - QSFP-DD (100GE)\n* `100gbase-x-sfp112` - SFP112 (100GE)\n* `100gbase-x-sfpdd` - SFP-DD (100GE)\n* `200gbase-x-cfp2` - CFP2 (200GE)\n* `200gbase-x-qsfp56` - QSFP56 (200GE)\n* `200gbase-x-qsfpdd` - QSFP-DD (200GE)\n* `400gbase-x-qsfp112` - QSFP112 (400GE)\n* `400gbase-x-qsfpdd` - QSFP-DD (400GE)\n* `400gbase-x-cdfp` - CDFP (400GE)\n* `400gbase-x-cfp2` - CFP2 (400GE)\n* `400gbase-x-cfp8` - CPF8 (400GE)\n* `400gbase-x-osfp` - OSFP (400GE)\n* `400gbase-x-osfp-rhs` - OSFP-RHS (400GE)\n* `800gbase-x-osfp` - OSFP (800GE)\n* `800gbase-x-qsfpdd` - QSFP-DD (800GE)\n* `1.6tbase-x-osfp1600` - OSFP1600 (1.6TE)\n* `1.6tbase-x-osfp1600-rhs` - OSFP1600-RHS (1.6TE)\n* `1.6tbase-x-qsfpdd1600` - QSFP-DD1600 (1.6TE)\n* `1000base-kx` - 1000BASE-KX (1GE)\n* `2.5gbase-kx` - 2.5GBASE-KX (2.5GE)\n* `5gbase-kr` - 5GBASE-KR (5GE)\n* `10gbase-kr` - 10GBASE-KR (10GE)\n* `10gbase-kx4` - 10GBASE-KX4 (10GE)\n* `25gbase-kr` - 25GBASE-KR (25GE)\n* `40gbase-kr4` - 40GBASE-KR4 (40GE)\n* `50gbase-kr` - 50GBASE-KR (50GE)\n* `100gbase-kp4` - 100GBASE-KP4 (100GE)\n* `100gbase-kr2` - 100GBASE-KR2 (100GE)\n* `100gbase-kr4` - 100GBASE-KR4 (100GE)\n* `1.6tbase-kr8` - 1.6TBASE-KR8 (1.6TE)\n* `ieee802.11a` - IEEE 802.11a\n* `ieee802.11g` - IEEE 802.11b/g\n* `ieee802.11n` - IEEE 802.11n (Wi-Fi 4)\n* `ieee802.11ac` - IEEE 802.11ac (Wi-Fi 5)\n* `ieee802.11ad` - IEEE 802.11ad (WiGig)\n* `ieee802.11ax` - IEEE 802.11ax (Wi-Fi 6)\n* `ieee802.11ay` - IEEE 802.11ay (WiGig)\n* `ieee802.11be` - IEEE 802.11be (Wi-Fi 7)\n* `ieee802.15.1` - IEEE 802.15.1 (Bluetooth)\n* `ieee802.15.4` - IEEE 802.15.4 (LR-WPAN)\n* `other-wireless` - Other (Wireless)\n* `gsm` - GSM\n* `cdma` - CDMA\n* `lte` - LTE\n* `4g` - 4G\n* `5g` - 5G\n* `sonet-oc3` - OC-3/STM-1\n* `sonet-oc12` - OC-12/STM-4\n* `sonet-oc48` - OC-48/STM-16\n* `sonet-oc192` - OC-192/STM-64\n* `sonet-oc768` - OC-768/STM-256\n* `sonet-oc1920` - OC-1920/STM-640\n* `sonet-oc3840` - OC-3840/STM-1234\n* `1gfc-sfp` - SFP (1GFC)\n* `2gfc-sfp` - SFP (2GFC)\n* `4gfc-sfp` - SFP (4GFC)\n* `8gfc-sfpp` - SFP+ (8GFC)\n* `16gfc-sfpp` - SFP+ (16GFC)\n* `32gfc-sfp28` - SFP28 (32GFC)\n* `32gfc-sfpp` - SFP+ (32GFC)\n* `64gfc-qsfpp` - QSFP+ (64GFC)\n* `64gfc-sfpdd` - SFP-DD (64GFC)\n* `64gfc-sfpp` - SFP+ (64GFC)\n* `128gfc-qsfp28` - QSFP28 (128GFC)\n* `infiniband-sdr` - SDR (2 Gbps)\n* `infiniband-ddr` - DDR (4 Gbps)\n* `infiniband-qdr` - QDR (8 Gbps)\n* `infiniband-fdr10` - FDR10 (10 Gbps)\n* `infiniband-fdr` - FDR (13.5 Gbps)\n* `infiniband-edr` - EDR (25 Gbps)\n* `infiniband-hdr` - HDR (50 Gbps)\n* `infiniband-ndr` - NDR (100 Gbps)\n* `infiniband-xdr` - XDR (200 Gbps)\n* `infiniband-sdr-4x` - SDR 4X (8 Gbps)\n* `infiniband-ddr-4x` - DDR 4X (16 Gbps)\n* `infiniband-qdr-4x` - QDR 4X (32 Gbps)\n* `infiniband-fdr10-4x` - FDR10 4X (40 Gbps)\n* `infiniband-fdr-4x` - FDR 4X (56 Gbps)\n* `infiniband-edr-4x` - EDR 4X (100 Gbps)\n* `infiniband-hdr-4x` - HDR 4X (200 Gbps)\n* `infiniband-ndr-4x` - NDR 4X (400 Gbps)\n* `infiniband-xdr-4x` - XDR 4X (800 Gbps)\n* `t1` - T1 (1.544 Mbps)\n* `e1` - E1 (2.048 Mbps)\n* `t3` - T3 (45 Mbps)\n* `e3` - E3 (34 Mbps)\n* `xdsl` - xDSL\n* `docsis` - DOCSIS\n* `moca` - MoCA\n* `bpon` - BPON (622 Mbps / 155 Mbps)\n* `epon` - EPON (1 Gbps)\n* `10g-epon` - 10G-EPON (10 Gbps)\n* `gpon` - GPON (2.5 Gbps / 1.25 Gbps)\n* `xg-pon` - XG-PON (10 Gbps / 2.5 Gbps)\n* `xgs-pon` - XGS-PON (10 Gbps)\n* `ng-pon2` - NG-PON2 (TWDM-PON) (4x10 Gbps)\n* `25g-pon` - 25G-PON (25 Gbps)\n* `50g-pon` - 50G-PON (50 Gbps)\n* `cisco-stackwise` - Cisco StackWise\n* `cisco-stackwise-plus` - Cisco StackWise Plus\n* `cisco-flexstack` - Cisco FlexStack\n* `cisco-flexstack-plus` - Cisco FlexStack Plus\n* `cisco-stackwise-80` - Cisco StackWise-80\n* `cisco-stackwise-160` - Cisco StackWise-160\n* `cisco-stackwise-320` - Cisco StackWise-320\n* `cisco-stackwise-480` - Cisco StackWise-480\n* `cisco-stackwise-1t` - Cisco StackWise-1T\n* `juniper-vcp` - Juniper VCP\n* `extreme-summitstack` - Extreme SummitStack\n* `extreme-summitstack-128` - Extreme SummitStack-128\n* `extreme-summitstack-256` - Extreme SummitStack-256\n* `extreme-summitstack-512` - Extreme SummitStack-512\n* `hpe-synergy-interconnect-link` - HPE Synergy Interconnect Link\n* `other` - Other", - "x-spec-enum-id": "b0c97040e5abdff1" + "description": "* `virtual` - Virtual\n* `bridge` - Bridge\n* `lag` - Link Aggregation Group (LAG)\n* `channel` - Channel\n* `100base-fx` - 100BASE-FX (10/100ME)\n* `100base-lfx` - 100BASE-LFX (10/100ME)\n* `100base-tx` - 100BASE-TX (10/100ME)\n* `100base-t1` - 100BASE-T1 (10/100ME)\n* `1000base-bx10-d` - 1000BASE-BX10-D (1GE BiDi Down)\n* `1000base-bx10-u` - 1000BASE-BX10-U (1GE BiDi Up)\n* `1000base-cwdm` - 1000BASE-CWDM (1GE)\n* `1000base-cx` - 1000BASE-CX (1GE DAC)\n* `1000base-dwdm` - 1000BASE-DWDM (1GE)\n* `1000base-ex` - 1000BASE-EX (1GE)\n* `1000base-lsx` - 1000BASE-LSX (1GE)\n* `1000base-lx` - 1000BASE-LX (1GE)\n* `1000base-lx10` - 1000BASE-LX10/LH (1GE)\n* `1000base-sx` - 1000BASE-SX (1GE)\n* `1000base-t` - 1000BASE-T (1GE)\n* `1000base-tx` - 1000BASE-TX (1GE)\n* `1000base-zx` - 1000BASE-ZX (1GE)\n* `2.5gbase-t` - 2.5GBASE-T (2.5GE)\n* `5gbase-t` - 5GBASE-T (5GE)\n* `10gbase-br-d` - 10GBASE-BR-D (10GE BiDi Down)\n* `10gbase-br-u` - 10GBASE-BR-U (10GE BiDi Up)\n* `10gbase-cu` - 10GBASE-CU (10GE DAC Passive Twinax)\n* `10gbase-cx4` - 10GBASE-CX4 (10GE DAC)\n* `10gbase-er` - 10GBASE-ER (10GE)\n* `10gbase-lr` - 10GBASE-LR (10GE)\n* `10gbase-lrm` - 10GBASE-LRM (10GE)\n* `10gbase-lx4` - 10GBASE-LX4 (10GE)\n* `10gbase-sr` - 10GBASE-SR (10GE)\n* `10gbase-t` - 10GBASE-T (10GE)\n* `10gbase-zr` - 10GBASE-ZR (10GE)\n* `25gbase-cr` - 25GBASE-CR (25GE DAC)\n* `25gbase-er` - 25GBASE-ER (25GE)\n* `25gbase-lr` - 25GBASE-LR (25GE)\n* `25gbase-sr` - 25GBASE-SR (25GE)\n* `25gbase-t` - 25GBASE-T (25GE)\n* `40gbase-cr4` - 40GBASE-CR4 (40GE DAC)\n* `40gbase-er4` - 40GBASE-ER4 (40GE)\n* `40gbase-fr4` - 40GBASE-FR4 (40GE)\n* `40gbase-lr4` - 40GBASE-LR4 (40GE)\n* `40gbase-sr4` - 40GBASE-SR4 (40GE)\n* `40gbase-sr4-bd` - 40GBASE-SR4 (40GE BiDi)\n* `50gbase-cr` - 50GBASE-CR (50GE DAC)\n* `50gbase-er` - 50GBASE-ER (50GE)\n* `50gbase-fr` - 50GBASE-FR (50GE)\n* `50gbase-lr` - 50GBASE-LR (50GE)\n* `50gbase-sr` - 50GBASE-SR (50GE)\n* `100gbase-cr1` - 100GBASE-CR1 (100GE DAC)\n* `100gbase-cr2` - 100GBASE-CR2 (100GE DAC)\n* `100gbase-cr4` - 100GBASE-CR4 (100GE DAC)\n* `100gbase-cr10` - 100GBASE-CR10 (100GE DAC)\n* `100gbase-cwdm4` - 100GBASE-CWDM4 (100GE)\n* `100gbase-dr` - 100GBASE-DR (100GE)\n* `100gbase-er4` - 100GBASE-ER4 (100GE)\n* `100gbase-fr1` - 100GBASE-FR1 (100GE)\n* `100gbase-lr1` - 100GBASE-LR1 (100GE)\n* `100gbase-lr4` - 100GBASE-LR4 (100GE)\n* `100gbase-sr1` - 100GBASE-SR1 (100GE)\n* `100gbase-sr1.2` - 100GBASE-SR1.2 (100GE BiDi)\n* `100gbase-sr2` - 100GBASE-SR2 (100GE)\n* `100gbase-sr4` - 100GBASE-SR4 (100GE)\n* `100gbase-sr10` - 100GBASE-SR10 (100GE)\n* `100gbase-zr` - 100GBASE-ZR (100GE)\n* `200gbase-cr2` - 200GBASE-CR2 (200GE)\n* `200gbase-cr4` - 200GBASE-CR4 (200GE)\n* `200gbase-dr4` - 200GBASE-DR4 (200GE)\n* `200gbase-er4` - 200GBASE-ER4 (200GE)\n* `200gbase-fr4` - 200GBASE-FR4 (200GE)\n* `200gbase-lr4` - 200GBASE-LR4 (200GE)\n* `200gbase-sr2` - 200GBASE-SR2 (200GE)\n* `200gbase-sr4` - 200GBASE-SR4 (200GE)\n* `200gbase-vr2` - 200GBASE-VR2 (200GE)\n* `400gbase-cr4` - 400GBASE-CR4 (400GE)\n* `400gbase-dr4` - 400GBASE-DR4 (400GE)\n* `400gbase-er8` - 400GBASE-ER8 (400GE)\n* `400gbase-fr4` - 400GBASE-FR4 (400GE)\n* `400gbase-fr8` - 400GBASE-FR8 (400GE)\n* `400gbase-lr4` - 400GBASE-LR4 (400GE)\n* `400gbase-lr8` - 400GBASE-LR8 (400GE)\n* `400gbase-sr4` - 400GBASE-SR4 (400GE)\n* `400gbase-sr4_2` - 400GBASE-SR4.2 (400GE BiDi)\n* `400gbase-sr8` - 400GBASE-SR8 (400GE)\n* `400gbase-sr16` - 400GBASE-SR16 (400GE)\n* `400gbase-vr4` - 400GBASE-VR4 (400GE)\n* `400gbase-zr` - 400GBASE-ZR (400GE)\n* `800gbase-cr8` - 800GBASE-CR8 (800GE)\n* `800gbase-dr8` - 800GBASE-DR8 (800GE)\n* `800gbase-sr8` - 800GBASE-SR8 (800GE)\n* `800gbase-vr8` - 800GBASE-VR8 (800GE)\n* `1.6tbase-cr8` - 1.6TBASE-CR8 (1.6TE)\n* `1.6tbase-dr8` - 1.6TBASE-DR8 (1.6TE)\n* `1.6tbase-dr8-2` - 1.6TBASE-DR8-2 (1.6TE)\n* `100base-x-sfp` - SFP (100ME)\n* `1000base-x-gbic` - GBIC (1GE)\n* `1000base-x-sfp` - SFP (1GE)\n* `2.5gbase-x-sfp` - SFP (2.5GE)\n* `10gbase-x-sfpp` - SFP+ (10GE)\n* `10gbase-x-xenpak` - XENPAK (10GE)\n* `10gbase-x-xfp` - XFP (10GE)\n* `10gbase-x-x2` - X2 (10GE)\n* `25gbase-x-sfp28` - SFP28 (25GE)\n* `40gbase-x-qsfpp` - QSFP+ (40GE)\n* `50gbase-x-sfp28` - QSFP28 (50GE)\n* `50gbase-x-sfp56` - SFP56 (50GE)\n* `100gbase-x-cfp` - CFP (100GE)\n* `100gbase-x-cfp2` - CFP2 (100GE)\n* `100gbase-x-cfp4` - CFP4 (100GE)\n* `100gbase-x-cxp` - CXP (100GE)\n* `100gbase-x-cpak` - Cisco CPAK (100GE)\n* `100gbase-x-dsfp` - DSFP (100GE)\n* `100gbase-x-qsfp28` - QSFP28 (100GE)\n* `100gbase-x-qsfpdd` - QSFP-DD (100GE)\n* `100gbase-x-sfp112` - SFP112 (100GE)\n* `100gbase-x-sfpdd` - SFP-DD (100GE)\n* `200gbase-x-cfp2` - CFP2 (200GE)\n* `200gbase-x-qsfp56` - QSFP56 (200GE)\n* `200gbase-x-qsfpdd` - QSFP-DD (200GE)\n* `400gbase-x-qsfp112` - QSFP112 (400GE)\n* `400gbase-x-qsfpdd` - QSFP-DD (400GE)\n* `400gbase-x-cdfp` - CDFP (400GE)\n* `400gbase-x-cfp2` - CFP2 (400GE)\n* `400gbase-x-cfp8` - CPF8 (400GE)\n* `400gbase-x-osfp` - OSFP (400GE)\n* `400gbase-x-osfp-rhs` - OSFP-RHS (400GE)\n* `800gbase-x-osfp` - OSFP (800GE)\n* `800gbase-x-qsfpdd` - QSFP-DD (800GE)\n* `1.6tbase-x-osfp1600` - OSFP1600 (1.6TE)\n* `1.6tbase-x-osfp1600-rhs` - OSFP1600-RHS (1.6TE)\n* `1.6tbase-x-qsfpdd1600` - QSFP-DD1600 (1.6TE)\n* `1000base-kx` - 1000BASE-KX (1GE)\n* `2.5gbase-kx` - 2.5GBASE-KX (2.5GE)\n* `5gbase-kr` - 5GBASE-KR (5GE)\n* `10gbase-kr` - 10GBASE-KR (10GE)\n* `10gbase-kx4` - 10GBASE-KX4 (10GE)\n* `25gbase-kr` - 25GBASE-KR (25GE)\n* `40gbase-kr4` - 40GBASE-KR4 (40GE)\n* `50gbase-kr` - 50GBASE-KR (50GE)\n* `100gbase-kp4` - 100GBASE-KP4 (100GE)\n* `100gbase-kr2` - 100GBASE-KR2 (100GE)\n* `100gbase-kr4` - 100GBASE-KR4 (100GE)\n* `1.6tbase-kr8` - 1.6TBASE-KR8 (1.6TE)\n* `ieee802.11a` - IEEE 802.11a\n* `ieee802.11g` - IEEE 802.11b/g\n* `ieee802.11n` - IEEE 802.11n (Wi-Fi 4)\n* `ieee802.11ac` - IEEE 802.11ac (Wi-Fi 5)\n* `ieee802.11ad` - IEEE 802.11ad (WiGig)\n* `ieee802.11ax` - IEEE 802.11ax (Wi-Fi 6)\n* `ieee802.11ay` - IEEE 802.11ay (WiGig)\n* `ieee802.11be` - IEEE 802.11be (Wi-Fi 7)\n* `ieee802.15.1` - IEEE 802.15.1 (Bluetooth)\n* `ieee802.15.4` - IEEE 802.15.4 (LR-WPAN)\n* `other-wireless` - Other (Wireless)\n* `gsm` - GSM\n* `cdma` - CDMA\n* `lte` - LTE\n* `4g` - 4G\n* `5g` - 5G\n* `sonet-oc3` - OC-3/STM-1\n* `sonet-oc12` - OC-12/STM-4\n* `sonet-oc48` - OC-48/STM-16\n* `sonet-oc192` - OC-192/STM-64\n* `sonet-oc768` - OC-768/STM-256\n* `sonet-oc1920` - OC-1920/STM-640\n* `sonet-oc3840` - OC-3840/STM-1234\n* `1gfc-sfp` - SFP (1GFC)\n* `2gfc-sfp` - SFP (2GFC)\n* `4gfc-sfp` - SFP (4GFC)\n* `8gfc-sfpp` - SFP+ (8GFC)\n* `16gfc-sfpp` - SFP+ (16GFC)\n* `32gfc-sfp28` - SFP28 (32GFC)\n* `32gfc-sfpp` - SFP+ (32GFC)\n* `64gfc-qsfpp` - QSFP+ (64GFC)\n* `64gfc-sfpdd` - SFP-DD (64GFC)\n* `64gfc-sfpp` - SFP+ (64GFC)\n* `128gfc-qsfp28` - QSFP28 (128GFC)\n* `infiniband-sdr` - SDR (2 Gbps)\n* `infiniband-ddr` - DDR (4 Gbps)\n* `infiniband-qdr` - QDR (8 Gbps)\n* `infiniband-fdr10` - FDR10 (10 Gbps)\n* `infiniband-fdr` - FDR (13.5 Gbps)\n* `infiniband-edr` - EDR (25 Gbps)\n* `infiniband-hdr` - HDR (50 Gbps)\n* `infiniband-ndr` - NDR (100 Gbps)\n* `infiniband-xdr` - XDR (200 Gbps)\n* `infiniband-sdr-4x` - SDR 4X (8 Gbps)\n* `infiniband-ddr-4x` - DDR 4X (16 Gbps)\n* `infiniband-qdr-4x` - QDR 4X (32 Gbps)\n* `infiniband-fdr10-4x` - FDR10 4X (40 Gbps)\n* `infiniband-fdr-4x` - FDR 4X (56 Gbps)\n* `infiniband-edr-4x` - EDR 4X (100 Gbps)\n* `infiniband-hdr-4x` - HDR 4X (200 Gbps)\n* `infiniband-ndr-4x` - NDR 4X (400 Gbps)\n* `infiniband-xdr-4x` - XDR 4X (800 Gbps)\n* `t1` - T1 (1.544 Mbps)\n* `e1` - E1 (2.048 Mbps)\n* `t3` - T3 (45 Mbps)\n* `e3` - E3 (34 Mbps)\n* `xdsl` - xDSL\n* `docsis` - DOCSIS\n* `moca` - MoCA\n* `bpon` - BPON (622 Mbps / 155 Mbps)\n* `epon` - EPON (1 Gbps)\n* `10g-epon` - 10G-EPON (10 Gbps)\n* `gpon` - GPON (2.5 Gbps / 1.25 Gbps)\n* `xg-pon` - XG-PON (10 Gbps / 2.5 Gbps)\n* `xgs-pon` - XGS-PON (10 Gbps)\n* `ng-pon2` - NG-PON2 (TWDM-PON) (4x10 Gbps)\n* `25g-pon` - 25G-PON (25 Gbps)\n* `50g-pon` - 50G-PON (50 Gbps)\n* `cisco-stackwise` - Cisco StackWise\n* `cisco-stackwise-plus` - Cisco StackWise Plus\n* `cisco-flexstack` - Cisco FlexStack\n* `cisco-flexstack-plus` - Cisco FlexStack Plus\n* `cisco-stackwise-80` - Cisco StackWise-80\n* `cisco-stackwise-160` - Cisco StackWise-160\n* `cisco-stackwise-320` - Cisco StackWise-320\n* `cisco-stackwise-480` - Cisco StackWise-480\n* `cisco-stackwise-1t` - Cisco StackWise-1T\n* `juniper-vcp` - Juniper VCP\n* `extreme-summitstack` - Extreme SummitStack\n* `extreme-summitstack-128` - Extreme SummitStack-128\n* `extreme-summitstack-256` - Extreme SummitStack-256\n* `extreme-summitstack-512` - Extreme SummitStack-512\n* `hpe-synergy-interconnect-link` - HPE Synergy Interconnect Link\n* `other` - Other", + "x-spec-enum-id": "19cc901fcea417ad" + }, + "channels": { + "type": "integer", + "maximum": 1024, + "minimum": 1, + "nullable": true, + "description": "The number of channels into which this interface is channelized" + }, + "channel_id": { + "type": "integer", + "maximum": 1024, + "minimum": 1, + "nullable": true, + "description": "The channel on the parent interface to which this subinterface is bound" }, "enabled": { "type": "boolean" @@ -326736,6 +363702,11 @@ "type": "string", "maxLength": 200 }, + "parent": { + "type": "integer", + "nullable": true, + "title": "Parent interface" + }, "bridge": { "type": "integer", "nullable": true, @@ -327361,6 +364332,20 @@ "x-spec-enum-id": "5ad4e700c656b09d", "nullable": true }, + "cooling_method": { + "enum": [ + "air", + "liquid", + "hybrid", + "immersion", + "", + null + ], + "type": "string", + "description": "* `air` - Air\n* `liquid` - Liquid\n* `hybrid` - Hybrid\n* `immersion` - Immersion", + "x-spec-enum-id": "fff5375d415a4fdc", + "nullable": true + }, "weight": { "type": "number", "format": "double", @@ -327384,6 +364369,12 @@ "x-spec-enum-id": "2235ce3f404afbc0", "nullable": true }, + "end_of_life": { + "type": "string", + "format": "date", + "nullable": true, + "description": "The date after which this module type is no longer supported by the manufacturer" + }, "description": { "type": "string", "maxLength": 200 @@ -327391,6 +364382,12 @@ "attributes": { "nullable": true }, + "module_bay_types": { + "type": "array", + "items": { + "type": "integer" + } + }, "owner": { "oneOf": [ { @@ -328905,6 +365902,28 @@ "x-spec-enum-id": "a784734d07ef1b3c", "nullable": true }, + "cooling_capability": { + "enum": [ + "air-only", + "hybrid", + "liquid-only", + "", + null + ], + "type": "string", + "description": "* `air-only` - Air only\n* `hybrid` - Hybrid\n* `liquid-only` - Liquid only", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + }, + "cooling_capacity": { + "type": "number", + "format": "double", + "maximum": 100000000, + "minimum": 0, + "exclusiveMaximum": true, + "nullable": true, + "description": "Cooling capacity (kW)" + }, "description": { "type": "string", "maxLength": 200 @@ -329188,6 +366207,28 @@ "nullable": true, "description": "Maximum depth of a mounted device, in millimeters. For four-post racks, this is the distance between the front and rear rails." }, + "cooling_capability": { + "enum": [ + "air-only", + "hybrid", + "liquid-only", + "", + null + ], + "type": "string", + "description": "* `air-only` - Air only\n* `hybrid` - Hybrid\n* `liquid-only` - Liquid only", + "x-spec-enum-id": "ebb8b6ef659d4888", + "nullable": true + }, + "cooling_capacity": { + "type": "number", + "format": "double", + "maximum": 100000000, + "minimum": 0, + "exclusiveMaximum": true, + "nullable": true, + "description": "Cooling capacity (kW)" + }, "owner": { "oneOf": [ { @@ -329611,7 +366652,7 @@ }, "WritableServiceRequest": { "type": "object", - "description": "Base serializer class for models inheriting from PrimaryModel.", + "description": "Shared port-mapping handling for the Service and ServiceTemplate serializers, including backward\ncompatibility for the legacy single-protocol ``protocol``/``ports`` representation.\n\nRead: alongside the ``port_mappings`` list, a service that uses a single protocol also reports the\nlegacy ``protocol`` and ``ports`` fields; a multi-protocol service reports ``null`` for both (it\ncannot be expressed in the old single-protocol format).\n\nWrite: either format is accepted. When the legacy ``protocol``/``ports`` pair is supplied (and\n``port_mappings`` is not), it is translated into ``port_mappings``. Supplying both together is\naccepted only when the legacy fields agree with what ``port_mappings`` implies (e.g. a full-object\nround-trip that echoes back the read representation); a genuine conflict is rejected as ambiguous.\n\nSubclassing ``serializers.Serializer`` (rather than a plain mixin) lets DRF's metaclass collect the\nfields declared here into the inheriting serializers.", "properties": { "parent_object_type": { "type": "string" @@ -329627,15 +366668,24 @@ "minLength": 1, "maxLength": 100 }, + "port_mappings": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "protocol": { "enum": [ "tcp", "udp", - "sctp" + "sctp", + null ], "type": "string", - "description": "* `tcp` - TCP\n* `udp` - UDP\n* `sctp` - SCTP", - "x-spec-enum-id": "e4b15bec749a2a32" + "description": "Deprecated; use port_mappings. Reported only for single-protocol services.", + "x-spec-enum-id": "e4b15bec749a2a32", + "nullable": true }, "ports": { "type": "array", @@ -329644,7 +366694,8 @@ "maximum": 65535, "minimum": 1 }, - "title": "Port numbers" + "nullable": true, + "description": "Deprecated; use port_mappings. Reported only for single-protocol services." }, "ipaddresses": { "type": "array", @@ -329689,29 +366740,36 @@ "required": [ "name", "parent_object_id", - "parent_object_type", - "ports", - "protocol" + "parent_object_type" ] }, "WritableServiceTemplateRequest": { "type": "object", - "description": "Base serializer class for models inheriting from PrimaryModel.", + "description": "Shared port-mapping handling for the Service and ServiceTemplate serializers, including backward\ncompatibility for the legacy single-protocol ``protocol``/``ports`` representation.\n\nRead: alongside the ``port_mappings`` list, a service that uses a single protocol also reports the\nlegacy ``protocol`` and ``ports`` fields; a multi-protocol service reports ``null`` for both (it\ncannot be expressed in the old single-protocol format).\n\nWrite: either format is accepted. When the legacy ``protocol``/``ports`` pair is supplied (and\n``port_mappings`` is not), it is translated into ``port_mappings``. Supplying both together is\naccepted only when the legacy fields agree with what ``port_mappings`` implies (e.g. a full-object\nround-trip that echoes back the read representation); a genuine conflict is rejected as ambiguous.\n\nSubclassing ``serializers.Serializer`` (rather than a plain mixin) lets DRF's metaclass collect the\nfields declared here into the inheriting serializers.", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 100 }, + "port_mappings": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "protocol": { "enum": [ "tcp", "udp", - "sctp" + "sctp", + null ], "type": "string", - "description": "* `tcp` - TCP\n* `udp` - UDP\n* `sctp` - SCTP", - "x-spec-enum-id": "e4b15bec749a2a32" + "description": "Deprecated; use port_mappings. Reported only for single-protocol services.", + "x-spec-enum-id": "e4b15bec749a2a32", + "nullable": true }, "ports": { "type": "array", @@ -329720,7 +366778,8 @@ "maximum": 65535, "minimum": 1 }, - "title": "Port numbers" + "nullable": true, + "description": "Deprecated; use port_mappings. Reported only for single-protocol services." }, "description": { "type": "string", @@ -329757,9 +366816,7 @@ } }, "required": [ - "name", - "ports", - "protocol" + "name" ] }, "WritableSiteGroupRequest": { @@ -330377,7 +367434,7 @@ }, "WritableVMInterfaceRequest": { "type": "object", - "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", + "description": "Mixin for Interface and VMInterface serializers that adds a `mac_address` shortcut field for\ncreating/updating the primary MACAddress in a single request. The validated write is centralized\non the interface model (BaseInterface.set_primary_mac_address); this mixin is a thin adapter that\nowns the permission check, the shortcut-vs-primary_mac_address conflict guard, and translating the\nmodel's validation errors into API errors.", "properties": { "virtual_machine": { "oneOf": [ @@ -330413,6 +367470,11 @@ "minimum": 1, "nullable": true }, + "mac_address": { + "type": "string", + "nullable": true, + "minLength": 1 + }, "primary_mac_address": { "oneOf": [ { @@ -330897,7 +367959,7 @@ "status" ] }, - "WritableVirtualMachineWithConfigContextRequest": { + "WritableVirtualMachineRequest": { "type": "object", "description": "Base serializer class for models inheriting from PrimaryModel.", "properties": { diff --git a/docs/administration/error-reporting.md b/docs/administration/error-reporting.md index 8b4312273..10dc71f31 100644 --- a/docs/administration/error-reporting.md +++ b/docs/administration/error-reporting.md @@ -4,11 +4,13 @@ ### Enabling Error Reporting -NetBox supports native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, set `SENTRY_ENABLED` to `True` and define your unique [data source name (DSN)](https://docs.sentry.io/product/sentry-basics/concepts/dsn-explainer/) in `configuration.py`. +NetBox supports native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, set `SENTRY_ENABLED` to `True` and define your unique [data source name (DSN)](https://docs.sentry.io/product/sentry-basics/concepts/dsn-explainer/) in `configuration.py` via `SENTRY_CONFIG`. ```python SENTRY_ENABLED = True -SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0" +SENTRY_CONFIG = { + "dsn": "https://examplePublicKey@o0.ingest.sentry.io/0", +} ``` Setting `SENTRY_ENABLED` to False will disable the Sentry integration. diff --git a/docs/administration/management-commands.md b/docs/administration/management-commands.md index 8fe0f0a26..0e7f604b8 100644 --- a/docs/administration/management-commands.md +++ b/docs/administration/management-commands.md @@ -28,14 +28,22 @@ python3 netbox/manage.py nbshell ## populate_image_sizes -!!! info "This command was introduced in NetBox v4.6.4." - Populate the cached file size for image attachments that predate the `image_size` field. Running this once after upgrading is recommended for deployments with many existing attachments on a remote storage backend (such as S3). It is safe to run on a live system and may be re-run; any file that cannot be read is skipped and retried on the next run. ``` python3 netbox/manage.py populate_image_sizes ``` +## rebuild_config_context_cache + +Pre-render and cache the merged config context data for all devices and virtual machines. The [upgrade script](../installation/upgrading.md) runs this automatically, so it is not usually necessary to invoke it by hand. It is useful to complete an interrupted run, or (with `--force`) to repair the cache after a bulk write which bypassed NetBox's change handling (cache invalidation is driven by model signals, which a direct `queryset.update()` does not emit). + +By default, only those objects whose cache is empty are rendered, so the command is safe to interrupt and re-run. This also means that a default run will not correct a cache which is populated but stale, as a write which bypassed cache invalidation leaves it: Pass `--force` to re-render every object regardless of its current cache. Either form may be run on a live system, as any object whose cache is empty falls back to rendering its config context on demand. See [Context Data](../features/context-data.md) for details. + +``` +python3 netbox/manage.py rebuild_config_context_cache [--force] +``` + ## rebuild_prefixes Rebuild the IPAM prefix hierarchy, recalculating the depth and child counts for all prefixes. @@ -62,6 +70,9 @@ python3 netbox/manage.py renaturalize [app_label.ModelName ...] ## runscript +!!! warning "Deprecation Warning" + The custom scripts functionality has been deprecated beginning in NetBox v4.7, and is scheduled for removal in NetBox v5.0. This command will be removed along with it. + Run a [custom script](../customization/custom-scripts.md) from the command line, outside the web UI or API. ``` diff --git a/docs/configuration/data-validation.md b/docs/configuration/data-validation.md index 2f00814f3..f205869a9 100644 --- a/docs/configuration/data-validation.md +++ b/docs/configuration/data-validation.md @@ -56,6 +56,20 @@ FIELD_CHOICES = { } ``` +In addition to plain tuples, each choice may be defined as a dictionary, which allows specifying a description (shown as a subtitle beneath the option) alongside the value, label, and color. `value` and `label` are required; `color` and `description` are optional: + +```python +FIELD_CHOICES = { + 'dcim.Site.status': ( + {'value': 'foo', 'label': 'Foo', 'color': 'red', 'description': 'The foo status'}, + {'value': 'bar', 'label': 'Bar', 'color': 'green'}, + ) +} +``` + +!!! info "New in NetBox v4.7" + The dictionary-based format for declaring choices was introduced in NetBox v4.7. The tuple-based format remains supported, but will be deprecated in a future release and support for it will eventually be removed. + !!! info "Case-Insensitive Field Identifiers" Field identifiers are case-insensitive. Both `dcim.Site.status` and `dcim.site.status` are valid and equivalent. diff --git a/docs/configuration/error-reporting.md b/docs/configuration/error-reporting.md index de0d7091b..21e58eb92 100644 --- a/docs/configuration/error-reporting.md +++ b/docs/configuration/error-reporting.md @@ -16,27 +16,6 @@ The default configuration is shown below: Additionally, `http_proxy` and `https_proxy` are set to the HTTP and HTTPS proxies, respectively, configured for NetBox (if any). -## SENTRY_DSN - -!!! warning "This parameter will be removed in NetBox v4.7." - Set this using `SENTRY_CONFIG` instead: - - ``` - SENTRY_CONFIG = { - "dsn": "https://examplePublicKey@o0.ingest.sentry.io/0", - } - ``` - -Default: `None` - -Defines a Sentry data source name (DSN) for automated error reporting. `SENTRY_ENABLED` must be `True` for this parameter to take effect. For example: - -``` -SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0" -``` - ---- - ## SENTRY_ENABLED Default: `False` @@ -48,43 +27,6 @@ Set to `True` to enable automatic error reporting via [Sentry](https://sentry.io --- -## SENTRY_SAMPLE_RATE - -!!! warning "This parameter will be removed in NetBox v4.7." - Set this using `SENTRY_CONFIG` instead: - - ``` - SENTRY_CONFIG = { - "sample_rate": 0.2, - } - ``` - -Default: `1.0` (all) - -The sampling rate for errors. Must be a value between 0 (disabled) and 1.0 (report on all errors). - ---- - -## SENTRY_SEND_DEFAULT_PII - -!!! warning "This parameter will be removed in NetBox v4.7." - Set this using `SENTRY_CONFIG` instead: - - ``` - SENTRY_CONFIG = { - "send_default_pii": True, - } - ``` - -Default: `False` - -Maps to the Sentry SDK's [`send_default_pii`](https://docs.sentry.io/platforms/python/configuration/options/#send-default-pii) parameter. If enabled, certain personally identifiable information (PII) is added. - -!!! warning "Sensitive data" - If you enable this option, be aware that sensitive data such as cookies and authentication tokens will be logged. - ---- - ## SENTRY_TAGS An optional dictionary of tag names and values to apply to Sentry error reports.For example: @@ -99,22 +41,3 @@ SENTRY_TAGS = { !!! warning "Reserved tag prefixes" Avoid using any tag names which begin with `netbox.`, as this prefix is reserved by the NetBox application. ---- - -## SENTRY_TRACES_SAMPLE_RATE - -!!! warning "This parameter will be removed in NetBox v4.7." - Set this using `SENTRY_CONFIG` instead: - - ``` - SENTRY_CONFIG = { - "traces_sample_rate": 0.2, - } - ``` - -Default: `0` (disabled) - -The sampling rate for transactions. Must be a value between 0 (disabled) and 1.0 (report on all transactions). - -!!! warning "Consider performance implications" - A high sampling rate for transactions can induce significant performance penalties. If transaction reporting is desired, it is recommended to use a relatively low sample rate of 10% to 20% (0.1 to 0.2). diff --git a/docs/configuration/graphql-api.md b/docs/configuration/graphql-api.md index 3fed0482b..f1a1dce1e 100644 --- a/docs/configuration/graphql-api.md +++ b/docs/configuration/graphql-api.md @@ -2,8 +2,6 @@ ## GRAPHQL_DEFAULT_VERSION -!!! note "This parameter was introduced in NetBox v4.5." - Default: `1` Designates the default version of the GraphQL API served by `/graphql/`. To access a specific version, append the version number to the URL, e.g. `/graphql/v2/`. @@ -30,8 +28,6 @@ The maximum number of queries that a GraphQL API request may contain. ## GRAPHQL_MAX_QUERY_DEPTH -!!! note "This parameter was introduced in NetBox v4.6.1." - Default: `None` (no limit) The maximum allowed depth of any GraphQL query. When set to a positive integer, requests containing queries that exceed this depth will be rejected. Leaving this parameter unset (or setting it to `None` or `0`) disables query depth enforcement. diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 0ae37b134..fe68d7d7a 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -6,6 +6,9 @@ NetBox's configuration file contains all the important parameters which control The configuration file is loaded from `$INSTALL_ROOT/netbox/netbox/configuration.py` by default. An example configuration is provided at `configuration_example.py`, which you may copy to use as your default config. Note that a configuration file must be defined; NetBox will not run without one. +!!! note "Python package installations (experimental)" + An experimental Python package installation loads `$NETBOX_ROOT/conf/configuration.py` by default. `NETBOX_ROOT` defaults to `/opt/netbox`. Use `netbox setup --target ` to scaffold the local configuration, and keep configuration and mutable instance data outside the virtual environment and installed package. The setup target is not persisted; set `NETBOX_ROOT` for all commands and services when using a non-default path. + !!! info "Customizing the Configuration Module" A custom configuration module may be specified by setting the `NETBOX_CONFIGURATION` environment variable. This must be a dotted path to the desired Python module. For example, a file named `my_config.py` in the same directory as `settings.py` would be referenced as `netbox.my_config`. diff --git a/docs/configuration/miscellaneous.md b/docs/configuration/miscellaneous.md index 5acb097f8..ead4d402d 100644 --- a/docs/configuration/miscellaneous.md +++ b/docs/configuration/miscellaneous.md @@ -125,8 +125,6 @@ The maximum size (in bytes) of an incoming HTTP request (i.e. `GET` or `POST` da ## STREAMING_EXPORTS -!!! note "This parameter was introduced in NetBox v4.6." - Default: `False` When set to `True`, CSV bulk exports are returned as a streaming HTTP response, emitting rows to the client as they are rendered rather than buffering the entire dataset in memory first. This can significantly reduce memory usage and time-to-first-byte for very large exports. @@ -277,7 +275,10 @@ This is a wrapper for passing global configuration parameters to [Django RQ](htt Default: `300` -The maximum execution time of a background task (such as running a custom script), in seconds. +The maximum execution time of a background task (such as running a custom script), in seconds. This may also be expressed as a duration string such as `1h` or `30m`, which NetBox normalizes to seconds when comparing it against webhook timeouts. Set this to `-1` to disable the job timeout entirely. + +!!! note + A value of zero (or `None`) does not disable the timeout: RQ falls back to its own default of 180 seconds, and NetBox validates webhook timeouts against that value accordingly. --- @@ -306,3 +307,19 @@ The base unit for disk sizes. Set this to `1024` to use binary prefixes (MiB, Gi Default: `1000` The base unit for RAM sizes. Set this to `1024` to use binary prefixes (MiB, GiB, etc.) instead of decimal prefixes (MB, GB, etc.). + +--- + +## WEBHOOK_DEFAULT_TIMEOUT + +Default: `60` + +The default maximum time (in seconds) to wait for a response when sending a webhook. This value is used for any webhook which does not define its own timeout. Keeping this below [`RQ_DEFAULT_TIMEOUT`](#rq_default_timeout) gives an unresponsive receiver a chance to be cut off by the request timeout rather than by termination of the background job. + +This value must be an integer between 1 and 3600, and must be less than `RQ_DEFAULT_TIMEOUT`; NetBox will refuse to start otherwise. The same upper bound is enforced on the per-webhook [timeout](../models/extras/webhook.md#timeout) field. + +!!! warning "Upgrading" + If you have lowered `RQ_DEFAULT_TIMEOUT` to 60 seconds or less and have not set `WEBHOOK_DEFAULT_TIMEOUT`, NetBox will not start until you set `WEBHOOK_DEFAULT_TIMEOUT` to a value below your job timeout. + +!!! note + The timeout is applied separately to establishing the connection and to waiting for data, rather than to the request as a whole. A receiver which responds slowly but continuously can therefore keep a request open for longer than the configured value. `RQ_DEFAULT_TIMEOUT` remains the ultimate upper bound on how long a webhook job can occupy a worker. diff --git a/docs/configuration/required-parameters.md b/docs/configuration/required-parameters.md index e08c78890..1ba3ac99d 100644 --- a/docs/configuration/required-parameters.md +++ b/docs/configuration/required-parameters.md @@ -25,8 +25,6 @@ ALLOWED_HOSTS = ['*'] ## API_TOKEN_PEPPERS -!!! info "This parameter was introduced in NetBox v4.5." - [Cryptographic peppers](https://en.wikipedia.org/wiki/Pepper_(cryptography)) are employed to generate hashes of sensitive values on the server. This parameter defines the peppers used to hash v2 API tokens in NetBox. You must define at least one pepper before creating a v2 API token. See the [API documentation](../integrations/rest-api.md#authentication) for further information about how peppers are used. ```python @@ -39,7 +37,7 @@ API_TOKEN_PEPPERS = { !!! warning "Peppers are sensitive" Treat pepper values as extremely sensitive. Consider populating peppers from environment variables at initialization time rather than defining them in the configuration file, if feasible. -Peppers must be at least 50 characters in length and should comprise a random string with a diverse character set. Consider using the Python script at `$INSTALL_ROOT/netbox/generate_secret_key.py` to generate a pepper value. +Peppers must be at least 50 characters in length and should comprise a random string with a diverse character set. Consider using the Python script at `$INSTALL_ROOT/netbox/generate_secret_key.py` to generate a pepper value. For a Python package installation, run the virtual environment's `netbox secret-key` command instead. It is recommended to start with a pepper ID of `1`. Additional peppers can be introduced later as needed to begin rotating token hashes. @@ -59,7 +57,7 @@ See the [`DATABASES`](#databases) configuration below for usage. ## DATABASES -NetBox requires access to a PostgreSQL 14 or later database service to store data. Note that support for PostgreSQL 14 is deprecated and will be removed in NetBox v4.7; PostgreSQL 15 or later will be required. This service can run locally on the NetBox server or on a remote system. Databases are defined as named dictionaries: +NetBox requires access to a PostgreSQL 15 or later database service to store data. This service can run locally on the NetBox server or on a remote system. Databases are defined as named dictionaries: ```python DATABASES = { @@ -251,4 +249,4 @@ REDIS = { This is a secret, pseudorandom string used to assist in the creation new cryptographic hashes for passwords and HTTP cookies. The key defined here should not be shared outside the configuration file. `SECRET_KEY` can be changed at any time without impacting stored data, however be aware that doing so will invalidate all existing user sessions. NetBox deployments comprising multiple nodes must have the same secret key configured on all nodes. -`SECRET_KEY` **must** be at least 50 characters in length, and should contain a mix of letters, digits, and symbols. The script located at `$INSTALL_ROOT/netbox/generate_secret_key.py` may be used to generate a suitable key. Please note that this key is **not** used directly for hashing user passwords or for the encrypted storage of secret data in NetBox. +`SECRET_KEY` **must** be at least 50 characters in length, and should contain a mix of letters, digits, and symbols. The script located at `$INSTALL_ROOT/netbox/generate_secret_key.py` may be used to generate a suitable key. For a Python package installation, run the virtual environment's `netbox secret-key` command instead. Please note that this key is **not** used directly for hashing user passwords or for the encrypted storage of secret data in NetBox. diff --git a/docs/configuration/security.md b/docs/configuration/security.md index 3d6ed3cef..503454289 100644 --- a/docs/configuration/security.md +++ b/docs/configuration/security.md @@ -6,7 +6,7 @@ Default: `('file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp')` -A list of permitted URL schemes referenced when rendering links within NetBox. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate all the default values as well (excluding those schemes which are not desirable). +A list of permitted URL schemes referenced when rendering links within NetBox. This list is also enforced when validating the value of URL custom fields. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate all the default values as well (excluding those schemes which are not desirable). !!! note Image sources (``) are limited to HTTP(S) and relative URLs, subject to `ALLOWED_URL_SCHEMES`. @@ -171,9 +171,6 @@ Default: `True` When enabled, only authenticated users are permitted to access any part of NetBox. Disabling this will allow unauthenticated users to access most areas of NetBox (but not make any changes). -!!! info "Changed in NetBox v4.0.2" - Prior to NetBox v4.0.2, this setting was disabled by default. - --- ## LOGIN_TIMEOUT diff --git a/docs/configuration/system.md b/docs/configuration/system.md index 845a40b68..a48a6102a 100644 --- a/docs/configuration/system.md +++ b/docs/configuration/system.md @@ -12,6 +12,22 @@ BASE_PATH = 'netbox/' --- +## BULK_UPDATE_CHUNK_SIZE + +Default: `5000` + +The maximum number of rows to affect in a single SQL `UPDATE` statement when NetBox performs a bulk update across many objects (for example, when recalculating cached counters or backfilling custom field data). On very large tables, an unbounded update spanning millions of rows can exceed the database's configured statement timeout; splitting the work into batches of at most this many rows bounds each statement while keeping the overall operation atomic. + +Must be a positive integer, or `None` to disable chunking and issue each bulk update as a single unbounded statement. + +This parameter also determines when a custom field operation is deferred to a background job: creating a field with a default value, or deleting a field, is performed within the request only where the field's assigned object types hold no more than this many objects in total (see [field status](../customization/custom-fields.md#field-status)). Setting it to `None` therefore defers every such operation which affects any object. + +```python +BULK_UPDATE_CHUNK_SIZE = 5000 +``` + +--- + ## DATABASE_ROUTERS Default: `[]` (empty list) @@ -40,7 +56,7 @@ The filesystem path to NetBox's documentation. This is used when presenting cont In order to send email, NetBox needs an email server configured. The following items can be defined within the `EMAIL` configuration parameter: -* `SERVER` - Hostname or IP address of the email server (use `localhost` if running locally) +* `SERVER` - Hostname or IP address of the email server (required; use `localhost` if running locally) * `PORT` - TCP port to use for the connection (default: `25`) * `USERNAME` - Username with which to authenticate * `PASSWORD` - Password with which to authenticate @@ -54,6 +70,9 @@ In order to send email, NetBox needs an email server configured. The following i !!! note The `USE_SSL` and `USE_TLS` parameters are mutually exclusive. +!!! warning + `SERVER` must be defined in order to send email: A deployment which omits it raises an `InvalidMailer` exception when attempting to send. Note that this is raised at send time rather than at startup, so a misconfiguration here will not be apparent until NetBox first tries to send mail. + Email is sent from NetBox only for critical events or if configured for [logging](#logging). If you would like to test the email server configuration, Django provides a convenient [send_mail()](https://docs.djangoproject.com/en/stable/topics/email/#send-mail) function accessible within the NetBox shell: ```no-highlight @@ -63,8 +82,7 @@ Email is sent from NetBox only for critical events or if configured for [logging 'Test Email Subject', 'Test Email Body', 'noreply-netbox@example.com', - ['users@example.com'], - fail_silently=False + ['users@example.com'] ) ``` @@ -72,8 +90,6 @@ Email is sent from NetBox only for critical events or if configured for [logging ## HOSTNAME -!!! info "This parameter was introduced in NetBox v4.4." - Default: System hostname The hostname displayed in the user interface identifying the system on which NetBox is running. If not defined, this defaults to the system hostname as reported by Python's `platform.node()`. @@ -82,8 +98,6 @@ The hostname displayed in the user interface identifying the system on which Net ## HTTP_CLIENT_IP_HEADERS -!!! info "This parameter was introduced in NetBox v4.6.1." - Default: ```python @@ -128,7 +142,7 @@ A list of IP addresses recognized as internal to the system, used to control the example, the debugging toolbar will be viewable only when a client is accessing NetBox from one of the listed IP addresses (and [`DEBUG`](./development.md#debug) is `True`). -!!! info "New in NetBox v4.6" +!!! info "Enabling the toolbar for all clients" Setting this parameter to an empty list will enable the toolbar for all requests provided debugging is enabled: ```python @@ -152,7 +166,7 @@ Set this configuration parameter to `True` for NetBox deployments which do not h Default: `[]` -A list of system environment variable names which may be referenced from within Jinja2 templates via the built-in [`env`](#jinja2_filters) filter. Patterns may include wildcards (matched using Python's `fnmatch` syntax). Any variable whose name does not match an entry in this list cannot be referenced from a template. For example: +A list of system environment variable names which may be referenced from within Jinja templates via the built-in [`env`](#jinja_filters) filter. Patterns may include wildcards (matched using Python's `fnmatch` syntax). Any variable whose name does not match an entry in this list cannot be referenced from a template. For example: ```python JINJA_ENVIRONMENT_PARAMS = [ @@ -166,33 +180,39 @@ JINJA_ENVIRONMENT_PARAMS = [ --- -## JINJA2_FILTERS +## JINJA_FILTERS + +!!! info "Renamed in NetBox v4.7" + This parameter was formerly named `JINJA2_FILTERS`. The old name is still supported for backward compatibility but is deprecated and will be removed in NetBox v5.0. Default: `{}` -A dictionary of custom Jinja2 filters with the key being the filter name and the value being a callable. For more information see the [Jinja2 documentation](https://jinja.palletsprojects.com/en/3.1.x/api/#custom-filters). For example: +A dictionary of custom Jinja filters with the key being the filter name and the value being a callable. For more information see the [Jinja documentation](https://jinja.palletsprojects.com/en/3.1.x/api/#custom-filters). For example: ```python def uppercase(x): return str(x).upper() -JINJA2_FILTERS = { +JINJA_FILTERS = { 'uppercase': uppercase, } ``` -NetBox also registers the following filters by default. Any entry defined in `JINJA2_FILTERS` with the same name will override the default. +NetBox also registers the following filters by default. Any entry defined in `JINJA_FILTERS` with the same name will override the default. | Filter | Description | |---|---| | `env` | Returns the value of the system environment variable with the given name, provided its name matches an entry in [`JINJA_ENVIRONMENT_PARAMS`](#jinja_environment_params). Returns `None` if the variable is not defined or its name is not whitelisted. | -For example, given `JINJA_ENVIRONMENT_PARAMS = ['WEBHOOK_TOKEN_*']`, a Jinja2 template may reference an environment variable as: +For example, given `JINJA_ENVIRONMENT_PARAMS = ['WEBHOOK_TOKEN_*']`, a Jinja template may reference an environment variable as: ``` Authorization: Bearer {{ 'WEBHOOK_TOKEN_3' | env }} ``` +!!! tip "Plugin-provided filters" + Plugins can also register Jinja filters without requiring instance configuration. See [Jinja Config Templates](../plugins/development/config-templates.md) in the plugin development documentation. Instance-level `JINJA_FILTERS` always takes precedence over plugin-registered filters of the same name. + --- ## LOGGING @@ -262,6 +282,9 @@ The file path to the location where [custom reports](../customization/reports.md ## SCRIPTS_ROOT +!!! warning "Deprecation Warning" + The custom scripts functionality has been deprecated beginning in NetBox v4.7, and is scheduled for removal in NetBox v5.0. This parameter will be removed along with it. + Default: `$INSTALL_ROOT/netbox/scripts/` The file path to the location where [custom scripts](../customization/custom-scripts.md) will be kept. By default, this is the `netbox/scripts/` directory within the base NetBox installation path. diff --git a/docs/customization/custom-fields.md b/docs/customization/custom-fields.md index 2255fe3ea..ee1f582e7 100644 --- a/docs/customization/custom-fields.md +++ b/docs/customization/custom-fields.md @@ -17,7 +17,7 @@ Custom fields may be created by navigating to Customization > Custom Fields. Net * Boolean: True or false * Date: A date in ISO 8601 format (YYYY-MM-DD) * Date & time: A date and time in ISO 8601 format (YYYY-MM-DD HH:MM:SS) -* URL: This will be presented as a link in the web UI +* URL: This will be presented as a link in the web UI. Values are restricted to the schemes permitted by [`ALLOWED_URL_SCHEMES`](../configuration/security.md#allowed_url_schemes). A value entered without a scheme (e.g. `example.com`) is assumed to use `https` and stored as an absolute URL (e.g. `https://example.com`). * JSON: Arbitrary data stored in JSON format * Selection: A selection of one of several pre-defined custom choices * Multiple selection: A selection field which supports the assignment of multiple values @@ -37,7 +37,34 @@ Unless the field has been assigned a default value, creating a custom field does This matters only if you query the underlying `custom_field_data` JSON directly, for example in a custom script. The field's key is absent from an object's data until a value is assigned to it, so read it with `obj.cf['field_name']` or `obj.custom_field_data.get('field_name')` rather than by direct subscript. -Assigning a default value, by contrast, does write that value to every existing object at the time the field is created, so that objects can be filtered by it immediately. On a model with a very large number of objects, this can take some time. Note that a default added to a field which already exists is _not_ backfilled: objects with no value continue to report none until they are next saved. +Assigning a default value, by contrast, does write that value to every existing object at the time the field is created, so that objects can be filtered by it immediately. Note that a default added to a field which already exists is _not_ backfilled: objects with no value continue to report none until they are next saved. + +### Field Status + +!!! info "This behavior was introduced in NetBox v4.7.0." + +Creating a custom field with a default value, and deleting a custom field, both require rewriting the stored data of the objects the field applies to. Where the field is assigned to a large number of objects, this cannot be completed within the request, so it is handed to a background job instead and the field reports its status accordingly: + +| Status | Meaning | +| ------ | ------- | +| Active | The field is live and available for use. | +| Provisioning | The field's default value is being written to existing objects. | +| Deleting | The field's data is being removed from existing objects. | + +Whether a background job is required is determined by the total number of objects of the field's assigned object types, measured against the [`BULK_UPDATE_CHUNK_SIZE`](../configuration/system.md#bulk_update_chunk_size) configuration parameter — not by how many of those objects actually hold a value for the field. Deleting a field assigned to a large table is therefore deferred even where the field holds no data at all: NetBox cannot count the objects holding a value without scanning the entire table, which is the cost the threshold exists to avoid. + +A field is live only while active. During provisioning or deletion it does not appear on objects, in forms, in filters, or in either API, and its stored data is read and written by nothing but the job responsible for it; it becomes available (or disappears entirely) once the job completes. Objects created in the meantime are unaffected — a field being provisioned still supplies its default to new objects. + +A field which is not active cannot be modified while its job runs, as its configuration must not change under the job rewriting its data. This includes assigning it further object types, and unassigning those it already carries: such a change is rejected until the field is live again. + +A field pending deletion continues to occupy its name until its data has been removed, so that a new field cannot be created — and an existing field cannot be renamed — to a name whose old values are still present on objects. + +These operations require a running [background worker](../features/background-jobs.md) (`rqworker`). A field left mid-operation, for example because no worker was running or because its job failed, remains in its pending status until that job runs to completion. + +Such a field can always be deleted, whichever status it holds. Deleting one already pending deletion queues a fresh job to finish removing its data. A field left provisioning has no equivalent in-application retry: requeue its job from the background queues (**Admin > System > Background Tasks**, which requires a staff account), or delete the field and create it again. + +!!! note + Unassigning an object type from a custom field still removes the field's data from those objects immediately, and remains subject to the request timeout on very large tables. The same applies to renaming a custom field. ### Filtering @@ -109,6 +136,28 @@ When retrieving an object via the REST API, all of its custom data will be inclu ... ``` +Selection and multiple selection fields are returned as objects exposing both the stored value and its human-friendly label, following the same convention used by NetBox's built-in choice fields: + +```json + "custom_fields": { + "site_type": { + "value": "datacenter", + "label": "Data Center" + }, + "regions": [ + { + "value": "us-east", + "label": "US East" + }, + { + "value": "us-west", + "label": "US West" + } + ] + }, + ... +``` + To set or change these values, simply include nested JSON data. For example: ```json @@ -120,3 +169,7 @@ To set or change these values, simply include nested JSON data. For example: } } ``` + +As with built-in choice fields, selection custom fields are written by passing the raw value (e.g. `"site_type": "datacenter"`), not the `{value, label}` object returned on read. + +The GraphQL API's `custom_fields` field resolves selection and multiple selection values to the same `{value, label}` representation. diff --git a/docs/customization/custom-links.md b/docs/customization/custom-links.md index 265efe669..86a69ed84 100644 --- a/docs/customization/custom-links.md +++ b/docs/customization/custom-links.md @@ -28,10 +28,13 @@ The following context data is available within the template when rendering a cus |-----------|-------------------------------------------------------------------------------------------------------------------| | `object` | The NetBox object being displayed | | `debug` | A boolean indicating whether debugging is enabled | -| `request` | The current WSGI request | -| `user` | The current user (if authenticated) | +| `request` | A sanitized subset of the current request (see below) | +| `user` | The current user (if authenticated) | | `perms` | The [permissions](https://docs.djangoproject.com/en/stable/topics/auth/default/#permissions) assigned to the user | +!!! note "Changed in NetBox v4.7" + For security, `request` no longer exposes the full WSGI request object. Only a safe subset of attributes is available: `request.id`, `request.path`, `request.path_info`, `request.method`, `request.GET` (the query parameters), and `request.user` (the username). Sensitive data such as cookies, headers, and session state is no longer accessible from within a custom link template. + While most of the context variables listed above will have consistent attributes, the object will be an instance of the specific object being viewed when the link is rendered. Different models have different fields and properties, so you may need to some research to determine the attributes available for use within your template for a specific object type. Checking the REST API representation of an object is generally a convenient way to determine what attributes are available. You can also reference the NetBox source code directly for a comprehensive list. diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index 6ebac740a..77730a5e4 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -1,5 +1,10 @@ # Custom Scripts +!!! warning "Deprecation Warning" + Beginning in NetBox v4.7, the custom scripts functionality built into core NetBox has been deprecated. It is being replaced by a dedicated open source plugin, which offers an expanded feature set including the organization of scripts into projects, the sharing of Python resources among scripts, and version control for individual scripts. + + The core implementation will remain available and supported throughout the v4.7 and v4.8 release cycles, and is scheduled for removal in NetBox v5.0. No immediate action is required: Existing scripts will continue to work as they do today, and users may migrate to the plugin at any point during the migration period. Migration is intended to be a largely automated process which should not require rewriting scripts. + Custom scripting was introduced to provide a way for users to execute custom logic from within the NetBox UI. Custom scripts enable the user to directly and conveniently manipulate NetBox data in a prescribed fashion. They can be used to accomplish myriad tasks, such as: * Automatically populate new devices and cables in preparation for a new site deployment diff --git a/docs/customization/reports.md b/docs/customization/reports.md index f7eef506d..4f117b54a 100644 --- a/docs/customization/reports.md +++ b/docs/customization/reports.md @@ -3,6 +3,8 @@ !!! warning Reports are deprecated beginning with NetBox v4.0, and their functionality has been merged with [custom scripts](./custom-scripts.md). While backward compatibility has been maintained, users are advised to convert legacy reports into custom scripts soon, as support for legacy reports will be removed in a future release. + Beginning with NetBox v4.7, NetBox's built-in custom scripts implementation is deprecated and is being replaced by a dedicated plugin. Converting a legacy report to a custom script remains the recommended first step. See the [custom scripts documentation](./custom-scripts.md) for details. + ## Converting Reports to Scripts ### Step 1: Update Class Definition diff --git a/docs/development/application-registry.md b/docs/development/application-registry.md index 7946d9df4..5a7057d43 100644 --- a/docs/development/application-registry.md +++ b/docs/development/application-registry.md @@ -16,10 +16,6 @@ A dictionary mapping of models to foreign keys with which cached counter fields A dictionary mapping data backend types to their respective classes. These are used to interact with [remote data sources](../models/core/datasource.md). -### `denormalized_fields` - -Stores registration made using `netbox.denormalized.register()`. For each model, a list of related models and their field mappings is maintained to facilitate automatic updates. - ### `filtersets` A dictionary mapping each model (identified by its app and label) to its filterset class, if one has been registered for it. Filtersets are registered using the `@register_filterset` decorator. diff --git a/docs/development/building-the-package.md b/docs/development/building-the-package.md index 0fa328ab9..3621111ec 100644 --- a/docs/development/building-the-package.md +++ b/docs/development/building-the-package.md @@ -1,8 +1,10 @@ # Building the Package -NetBox package artifacts (a wheel and a source distribution) can be built and verified locally. During the v4.6.x preview period, published artifacts are for maintainer validation only. Installing NetBox via pip is not a supported installation path yet. Experimental support for installing from production PyPI is planned for NetBox v4.7.0. This page is intended for maintainers and contributors working on the packaging itself; routine development does not require building a package. +NetBox package artifacts (a wheel and a source distribution) can be built and verified locally. Installing NetBox from the Python package is experimental in NetBox v4.7 and is not recommended for production use. This page is intended for maintainers and contributors working on the packaging itself. Routine development does not require building a package. -The artifacts are always built by CI from a clean checkout (see `.github/workflows/release.yml`). A local build is useful for testing packaging changes before they are merged. +Published artifacts are always built by CI from a clean checkout (see `.github/workflows/release.yml`). A local build is useful for testing packaging changes before they are merged. + +Release tags trigger the production PyPI publishing workflow. Before a release tag is pushed, confirm that the `pypi` GitHub Actions environment has required reviewers configured so the upload waits for approval after the package checks complete. Referencing the environment in the workflow does not create an approval gate by itself. See [Confirm Package Publishing Prerequisites](./release-checklist.md#confirm-package-publishing-prerequisites) and [Publish to PyPI](./release-checklist.md#publish-to-pypi) for the required repository checks and release procedure. ## Prerequisites @@ -12,7 +14,7 @@ Install the minimum local build tooling (all three are also included in the `dev python -m pip install --upgrade build packaging twine ``` -Building also requires a freshly rendered copy of the documentation site (see [Building](#building) below). The documentation toolchain (`zensical`, `mkdocs`, `mkdocs-material`, `mkdocstrings`) is pinned in `requirements.txt` rather than the `dev` group because it is also needed outside packaging, such as documentation previews and CI's `docs` job. +Building also requires a freshly rendered copy of the documentation site (see [Building](#building) below). The documentation toolchain, including `zensical`, `mkdocs`, `mkdocs-material`, `mkdocstrings`, and `mkdocstrings-python`, is pinned in `requirements.txt` rather than the `dev` group because it is also needed outside packaging, such as documentation previews and CI's `docs` job. ## Building @@ -41,7 +43,7 @@ The package version and the wheel's runtime dependency metadata are both compute ## Clean-tree caveat -Always build release artifacts from a clean checkout. The build configuration keeps deployment-local files out of the artifacts: the Hatch excludes drop every `configuration*.py` and `ldap_config*.py` except the two tracked configuration templates (`configuration_example.py` and `configuration_testing.py`, which are force-included explicitly), and CI verifies the contents of both the wheel and the sdist before anything is published. +Always build release artifacts from a clean checkout. The Hatch configuration excludes `netbox/netbox/configuration*.py` and `netbox/netbox/ldap_config*.py`, then force-includes only the two tracked configuration templates, `configuration_example.py` and `configuration_testing.py`. The sdist additionally excludes the checkout-level `netbox/configuration.py` and `netbox/ldap_config.py` symlinks. CI verifies the complete contents of both distributions before anything is published. These checks are defense in depth, not a license to build from a dirty tree: other untracked files under `netbox/` can still be picked up by a local build. CI builds from a clean checkout, so the published artifacts are unaffected. For a comparable local build, use a fresh `git clone` or a separate clean worktree rather than your day-to-day development tree. @@ -93,11 +95,11 @@ NETBOX_SMOKETEST_BASE=/tmp/netbox-build-test-root \ /tmp/netbox-build-test/bin/netbox check ``` -Without configuration, a wheel-installed NetBox looks for `$NETBOX_ROOT/conf/configuration.py` (default `/opt/netbox/conf/configuration.py`), which normally does not exist on a development workstation. The environment variables above point `netbox check` at the same minimal configuration module used by the release workflow's smoke-test job (`scripts/smoketest_configuration.py`); run the command from the repository root so `PYTHONPATH` can find it. `NETBOX_SMOKETEST_BASE` sets the writable scratch directory under which the module creates its media, reports, and scripts roots; `NETBOX_ROOT` points the fixed collected-static root at the same directory. Any other importable configuration module works the same way via `NETBOX_CONFIGURATION` (and `PYTHONPATH`, if the configuration lives outside the package). To exercise the full post-install task sequence from the wheel, run `netbox upgrade --no-input` with the same environment against a throwaway database (the collected static files land under `$NETBOX_ROOT/static`); this is what the release workflow's smoke-test job does. The documentation ships pre-rendered in the wheel, so there is nothing to build on the instance; `--build-docs` remains a checkout-only convenience for rendering the documentation from its sources. +Without configuration, a wheel-installed NetBox looks for `$NETBOX_ROOT/conf/configuration.py` (default `/opt/netbox/conf/configuration.py`), which normally does not exist on a development workstation. The environment variables above point `netbox check` at the same minimal configuration module used by the release workflow's smoke-test job (`scripts/smoketest_configuration.py`); run the command from the repository root so `PYTHONPATH` can find it. `NETBOX_SMOKETEST_BASE` sets the writable scratch directory under which the module creates its media, reports, and scripts roots. `NETBOX_ROOT` sets the instance root, from which the fixed collected-static path `$NETBOX_ROOT/static` is derived. Any other importable configuration module works the same way via `NETBOX_CONFIGURATION` (and `PYTHONPATH`, if the configuration lives outside the package). To exercise the full post-install task sequence from the wheel, run `netbox upgrade --no-input` with the same environment against a throwaway database (the collected static files land under `$NETBOX_ROOT/static`); this is what the release workflow's smoke-test job does. The documentation ships pre-rendered in the wheel, so there is nothing to build on the instance; `--build-docs` remains a checkout-only convenience for rendering the documentation from its sources. ## Packaging architecture -This section is a developer-facing overview of how the package is assembled and how a pip-installed NetBox behaves at runtime. User-facing installation documentation for the pip install path will be added alongside experimental PyPI support (planned for NetBox v4.7.0); this page does not cover end-user installation steps. +This section is a developer-facing overview of how the package is assembled and how a pip-installed NetBox behaves at runtime. End-user installation steps live in [Install NetBox from the Python Package](../installation/3b-python-package.md). ### Dynamic metadata @@ -105,7 +107,7 @@ This section is a developer-facing overview of how the package is assembled and ### sdist and the sdist-to-wheel guard -`python -m build` produces both an sdist and a wheel, with the wheel built from the sdist. The release workflow's `verify-sdist` job rebuilds a wheel from the candidate sdist and runs `scripts/verify_wheel_metadata.py` and `scripts/verify_wheel_contents.py` against it, so a missing build input (for example the metadata hook or `base_requirements.txt`) cannot regress unnoticed. The rendered documentation site is one such build input: it reaches the sdist through its own force-include (`[tool.hatch.build.targets.sdist.force-include]`), so this guard also fails if that force-include is removed or broken. +`python -m build` produces both an sdist and a wheel, with the wheel built from the sdist. The release workflow's `verify-sdist` job rebuilds a wheel from the candidate sdist and runs `scripts/verify_wheel_metadata.py` and `scripts/verify_wheel_contents.py` against it, so a missing build input, for example the metadata hook, `netbox/release.yaml`, or `requirements.txt`, cannot regress unnoticed. The rendered documentation site is one such build input: it reaches the sdist through its own force-include (`[tool.hatch.build.targets.sdist.force-include]`), so this guard also fails if that force-include is removed or broken. ### Wheel data layout diff --git a/docs/development/release-checklist.md b/docs/development/release-checklist.md index 68d176f13..9f80b9512 100644 --- a/docs/development/release-checklist.md +++ b/docs/development/release-checklist.md @@ -196,6 +196,16 @@ Once CI has completed and a colleague has reviewed the PR, merge it. This effect !!! warning To ensure a streamlined review process, the pull request for a release **must** be limited to the changes outlined in this document. A release PR must never include functional changes to the application: Any unrelated "cleanup" needs to be captured in a separate PR prior to the release being shipped. +### Confirm Package Publishing Prerequisites + +Complete these checks before creating the release tag. + +Confirm that the existing PyPI trusted publisher still matches this repository, `.github/workflows/release.yml`, and the `pypi` environment name. If a Test PyPI rehearsal is planned, confirm the corresponding Test PyPI trusted publisher and `testpypi` environment as well. The trusted publisher's environment name must match the publish job's `environment.name`, otherwise the index rejects the upload before any file is transferred. + +Confirm that the `pypi` GitHub Actions environment has required reviewers configured so the production upload waits for approval after the package checks complete. Enable **Prevent self-review**, restrict deployments to `v*` tags, and leave administrator bypass disabled unless the maintainers deliberately require it. Referencing an environment from the workflow does not configure these protection rules; if the environment does not exist, GitHub creates it without an approval gate. The `testpypi` environment does not need an approval gate because a rehearsal run is dispatched deliberately. + +The published package version is derived from `netbox/release.yaml` (the `version` field plus any `designation`, e.g. `beta1` becomes `4.7.0b1`), not from the git tag. Confirm that the intended tag and `netbox/release.yaml` agree before creating the release. The publishing workflow verifies the match again against the built wheel. + ### Create a New Release Create a [new release](https://github.com/netbox-community/netbox/releases/new) on GitHub with the following parameters. @@ -207,23 +217,54 @@ Create a [new release](https://github.com/netbox-community/netbox/releases/new) Once created, the release will become available for users to install from GitHub. -### Publish to Test PyPI +### Publish to PyPI -Pushing a release tag triggers the Python package publishing workflow, which publishes the tagged release automatically to **Test PyPI** for maintainer validation. Installing NetBox via pip is not a supported installation path during the v4.6.x preview period; production PyPI publishing is planned for the v4.7.0 feature branch. A manual `workflow_dispatch` run publishes to Test PyPI only when the selected ref is a `v*` release tag; dispatching from a branch runs the build and verification jobs as a dry run without publishing. +Creating the GitHub release pushes the new tag and starts the Python package publishing workflow. With the prerequisites above in place, the workflow builds and verifies the wheel and source distribution, then holds the production upload until the `pypi` deployment is approved. Approving the deployment publishes the verified artifacts to **PyPI**. Installing NetBox from the Python package is experimental in NetBox v4.7 and is not recommended for production use. + +A manual `workflow_dispatch` run from a `v*` release tag publishes to **Test PyPI** instead. This remains available as an optional rehearsal after packaging or publishing changes, but it is not required for every production release. Dispatching from a branch runs the build and verification jobs as a dry run without publishing anywhere. + +Dispatch a rehearsal from the release tag with GitHub CLI: + +```no-highlight +gh workflow run release.yml --ref vX.Y.Z +``` + +When a Test PyPI rehearsal is useful for a release, keep the production deployment awaiting approval while you dispatch the workflow from the same tag and validate the rehearsal. The rehearsal is a separate workflow run and rebuilds the distributions, so it validates the packaging and publishing path rather than the exact files waiting for production. Approve the production deployment after the rehearsal completes. + +Test PyPI enforces the same filename immutability. Once it has accepted either distribution generated for a release tag, dispatching that tag again is expected to fail because the workflow rebuilds the same wheel and source distribution filenames. A further rehearsal requires a new package version and matching tag. + +Official pre-release tags, including beta and release-candidate versions, are published to PyPI as well. This is intentional. Pip does not select pre-release versions by default unless the user explicitly requests one or no compatible stable release is available. After a publish run completes: * Verify that the build, CLI smoke-test (`cli-smoke-test`), smoke-test, dependency-verification (`verify-dependencies`), and sdist-verification (`verify-sdist`) jobs succeeded. The dependency-verification job fails the release if `requirements.txt` has drifted from `base_requirements.txt` or if the built wheel's `Requires-Dist` does not match `requirements.txt`; the sdist-verification job fails it if the sdist ships unexpected configuration files or cannot rebuild a valid wheel. -* Verify that the publish job used the expected trusted-publishing environment (`testpypi`). -* Confirm that the new version is visible on Test PyPI. -* Install the published wheel into a fresh virtual environment and run `netbox check` against a minimal configuration module. The preview artifact is published to Test PyPI while NetBox's pinned runtime dependencies are expected to resolve from PyPI; to avoid mixed-index dependency resolution during validation, install the pinned dependencies from PyPI first, then install the Test PyPI artifact without resolving dependencies again: +* Verify that the publish job used the expected trusted-publishing environment: `pypi` for a production release or `testpypi` for a rehearsal. +* Confirm that the new version is visible on the corresponding package index. +* Test the published wheel using the [wheel smoke-test procedure](./building-the-package.md#test-installing-the-wheel). For a production release, replace the local wheel installation command in that procedure with: ```no-highlight - pip install -r requirements.txt - pip install --no-deps --index-url https://test.pypi.org/simple/ netbox== + /tmp/netbox-build-test/bin/python -m pip install "netbox==" ``` -!!! note "Trusted publishing prerequisites" - Publishing requires a one-time setup by the project owners: a `netbox` project and a configured GitHub trusted publisher on Test PyPI, plus the corresponding `testpypi` GitHub Actions environment. + For a Test PyPI rehearsal, install NetBox's pinned runtime dependencies from PyPI first and then install the candidate without resolving dependencies from the test index: -The published package version is derived from `netbox/release.yaml` (the `version` field plus any `designation`, e.g. `beta1` becomes `4.7.0b1`), not from the git tag. Ensure the tag and `release.yaml` agree before tagging a pre-release. + ```no-highlight + /tmp/netbox-build-test/bin/python -m pip install -r requirements.txt + /tmp/netbox-build-test/bin/python -m pip install \ + --no-deps \ + --index-url https://test.pypi.org/simple/ \ + "netbox==" + ``` + + Run `netbox check` with the configuration and environment variables shown in the linked procedure. + +!!! warning "Production PyPI uploads are final" + Distribution files uploaded to PyPI cannot be replaced. A release may be yanked, and a release or an individual file may be deleted, but an uploaded filename can never be reused. Correcting an accepted distribution file requires publishing a new NetBox version. + + If the publish job fails, check PyPI and the job log to determine whether any distribution file was accepted before deciding how to recover. + + If no file was accepted and the cause can be corrected without changing the built distributions, correct it and re-run only the failed `publish-pypi` job. That reuses the package artifacts already built and verified in the original workflow run. Do not use **Re-run all jobs**, because it rebuilds the distributions. + + If correcting the failure requires changing package contents or metadata, prepare a new NetBox version and release tag instead. + + If PyPI accepted either distribution file, do not retry the publish job. Production publishing fails on duplicate filenames by design, so the retry fails when it reaches the already accepted file. Yank the incomplete release, record the accepted filenames and hashes, and publish a new NetBox version rather than combining files from separate builds. diff --git a/docs/features/context-data.md b/docs/features/context-data.md index d893fde2a..9735458c9 100644 --- a/docs/features/context-data.md +++ b/docs/features/context-data.md @@ -90,3 +90,14 @@ Devices and virtual machines may also have a local context data defined. This lo A [config context profile](../models/extras/configcontextprofile.md) provides an organizational grouping for related config contexts and may optionally enforce a [JSON schema](https://json-schema.org/) describing the shape of their data. When a profile is assigned to a config context, NetBox validates the context's data against the profile's schema on save and rejects any context that fails validation. This makes it possible to constrain which keys may appear in a context, require certain keys to be present, or limit values to a defined enumeration — guarding against typos and drift as contexts proliferate. A profile's schema may be authored directly in NetBox or populated from an external [data source](../models/core/datasource.md), enabling teams to maintain schemas alongside the code or configurations that consume them. + +## Pre-rendered Caching + +!!! info "This feature was introduced in NetBox v4.7." + +NetBox pre-renders each device's and virtual machine's merged context data and stores it on the object itself, so most reads can return the result without recomputing the full set of applicable contexts. The cache is initially populated during upgrade (the upgrade script runs the `rebuild_config_context_cache` management command) and is thereafter kept current automatically: whenever an upstream change is detected — a config context being created, modified, or deleted; a device/VM's scope-relevant attribute changing (site, role, tenant, tags, cluster, etc.); or a related object being re-routed in a way that changes which contexts apply — NetBox marks the affected caches invalid and enqueues a non-blocking [background job](./background-jobs.md) to repopulate them. + +During the brief window between invalidation and re-render, requests for the affected object's config context fall back to the original on-demand rendering path, so the data returned is always correct — never stale — but may be slightly slower during that window. Once the background job completes, reads are served from the cache. + +!!! note + The pre-rendered cache supersedes the previous `?exclude=config_context` REST API query parameter. Config context data is now always returned for devices and virtual machines, and the parameter is silently ignored. diff --git a/docs/features/cooling.md b/docs/features/cooling.md new file mode 100644 index 000000000..5b212ebd8 --- /dev/null +++ b/docs/features/cooling.md @@ -0,0 +1,43 @@ +# Cooling + +!!! info "This feature was introduced in NetBox v4.7." + +As part of its DCIM feature set, NetBox supports modeling data center cooling infrastructure, from facility plant down to the coolant connections on individual devices. This is used to document liquid- and hybrid-cooled environments (chillers, cooling distribution units, manifolds, rear-door heat exchangers, and cold-plate servers) as a source of truth. + +## Model Overview + +Cooling infrastructure is modeled as a hierarchy running from facility plant down to individual devices: + +**cooling source → cooling feed → device cooling intake / cooling outflow** + +A few properties of the model are worth noting up front: + +- **Connections are direct references, not cables.** Coolant hoses are not modeled as structured cabling; instead, an intake references the outflow that supplies it directly. Tracing a loop is a walk along these references. +- **A single feed represents the entire loop.** A cooling feed covers both the supply (cold) and return (warm) paths of a loop, rather than modeling each direction as a separate object. +- **Intakes and outflows both sit on the supply path.** Both device components describe the cold, coolant-distribution side of the loop: an intake receives coolant and an outflow passes it onward to downstream equipment. The warm return path is not modeled per-component — it is captured by the feed loop. + +## Cooling Sources + +A [cooling source](../models/dcim/coolingsource.md) is the furthest upstream cooling element modeled in NetBox, representing a chiller, cooling tower, dry cooler, or CRAC/CRAH unit. Each source is associated with a site, and may optionally be associated with a particular location within that site. A cooling source is not a device; it represents external facility plant, and records the coolant (fluid type) and total rated cooling capacity for the loops it originates. + +## Cooling Feeds + +A [cooling feed](../models/dcim/coolingfeed.md) represents a coolant loop running between a cooling source and a particular rack. Each feed records an operational status, a rated cooling capacity, and a rated (design) flow rate. + +## Device Components + +Devices participate in cooling through two component types, instantiated from templates defined on the device type: + +- A [cooling intake](../models/dcim/coolingintake.md) is a coolant intake on a device, such as a server cold-plate inlet or a CDU facility intake. It records the connector type, diameter, and rated maximum flow, and optionally references the upstream [cooling outflow](../models/dcim/coolingoutflow.md) that supplies it. +- A [cooling outflow](../models/dcim/coolingoutflow.md) is a coolant supply point on a device, such as a CDU or manifold outlet. It optionally references a parent cooling intake on the same device — the device takes coolant in through its intake and passes it back out through its outflow. + +!!! tip "In-rack cooling equipment is modeled as a device" + Coolant distribution units (CDUs), manifolds, and rear-door heat exchangers (RDHx) are modeled as ordinary (typically zero-U) [devices](../models/dcim/device.md) installed in the rack — exactly as a PDU is modeled as a device with power ports and outlets. The device's make and model come from its [device type](../models/dcim/devicetype.md), and its cooling connections are represented by cooling intake and outflow components. There is no dedicated CDU or RDHx model. + +## Racks and Devices + +Racks and devices carry lightweight cooling attributes independent of the feed/component topology: + +- A [rack](../models/dcim/rack.md) records a **cooling capability** (air-only, hybrid, or liquid-only) and a **cooling capacity** in kilowatts, typically inherited from its rack type. +- A [device](../models/dcim/device.md) records a **cooling method** (air, liquid, hybrid, or immersion), inherited from its device type and overridable per device. + diff --git a/docs/features/customization.md b/docs/features/customization.md index 4ea39691c..9c53d76d4 100644 --- a/docs/features/customization.md +++ b/docs/features/customization.md @@ -79,6 +79,9 @@ To learn more about this feature, check out the [documentation for reports](../c ## Custom Scripts +!!! warning "Deprecation Warning" + Beginning in NetBox v4.7, the custom scripts functionality built into core NetBox has been deprecated in favor of a dedicated plugin, and is scheduled for removal in NetBox v5.0. See the [custom scripts documentation](../customization/custom-scripts.md) for details. + Custom scripts are similar to reports, but more powerful. A custom script can prompt the user for input via a form (or API data), and is built to do much more than just reporting. Custom scripts are generally used to automate tasks, such as the population of new objects in NetBox, or exchanging data with external systems. As with reports, they can be run via the UI, REST API, or CLI, and be scheduled to execute at a future time. The complete Python environment is available to a custom script, including all of NetBox's internal mechanisms: There are no artificial restrictions on what a script can do. As such, custom scripting is considered an advanced feature and requires sufficient familiarity with Python and NetBox's data model. diff --git a/docs/features/resource-ownership.md b/docs/features/resource-ownership.md index a50984f5a..89aa47593 100644 --- a/docs/features/resource-ownership.md +++ b/docs/features/resource-ownership.md @@ -1,7 +1,5 @@ # Resource Ownership -!!! info "This feature was introduced in NetBox v4.5." - Most objects in NetBox can be assigned an owner. An owner is a set of users and/or groups who are responsible for the administration of associated objects. For example, you might designate the operations team at a site as the owner for all prefixes and VLANs deployed at that site. The users and groups assigned to an owner are referred to as its members. !!! note diff --git a/docs/features/search.md b/docs/features/search.md index 92422cad9..4b0842bd7 100644 --- a/docs/features/search.md +++ b/docs/features/search.md @@ -2,7 +2,7 @@ ## Global Search -NetBox includes a powerful global search engine, providing a single convenient interface to search across its complex data model. Relevant fields on each model are indexed according to their precedence, so that the most relevant results are returned first. When objects are created or modified, the search index is updated immediately, ensuring real-time accuracy. +NetBox includes a powerful global search engine, providing a single convenient interface to search across its complex data model. Relevant fields on each model are indexed according to their precedence, so that the most relevant results are returned first. When objects are created, modified, or deleted, the search index is updated by a background task shortly afterward. As a result, a newly created or changed object may not appear in search results for a brief period. (When no background worker is running, the index is updated immediately as part of the request.) When entering a search query, the user can choose a specific lookup type: exact match, partial match, etc. When a partial match is found, the matching portion of the applicable field value is included with each result so that the user can easily determine its relevance. diff --git a/docs/getting-started/populating-data.md b/docs/getting-started/populating-data.md index 9a2386d71..06701a6e7 100644 --- a/docs/getting-started/populating-data.md +++ b/docs/getting-started/populating-data.md @@ -26,7 +26,9 @@ When viewing the CSV import form for an object type, you'll notice that the head -If an "id" field is added the data will be used to update existing records instead of importing new objects. +If an "id" field is added the data will be used to update existing records instead of importing new objects. When updating, only the columns present in the data are applied; all others are left unchanged. Note that some columns are interdependent: for example, updating a cable's terminations requires that the columns identifying their type and parent object be included as well. + +Some columns accept multiple values, separated by commas. Because the comma also serves as the CSV field delimiter, such a value must be enclosed in double quotes, e.g. `"tag1,tag2,tag3"`. (When importing JSON- or YAML-formatted data, these columns accept a native list instead.) An object whose name itself contains a comma cannot be referenced by a multi-value column, as there is no way to distinguish it from a separator. Note that some models (namely device types and module types) do not support CSV import. Instead, they accept YAML-formatted data to facilitate the import of both the parent object as well as child components. diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md index adb535aaf..7dac7cf46 100644 --- a/docs/installation/1-postgresql.md +++ b/docs/installation/1-postgresql.md @@ -2,11 +2,8 @@ This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md). -!!! warning "PostgreSQL 14 or later required" - NetBox requires PostgreSQL 14 or later. Please note that MySQL and other relational databases are **not** supported. - -!!! warning "PostgreSQL 14 deprecation notice" - Support for PostgreSQL 14 is deprecated as of NetBox v4.6 and will be removed in NetBox v4.7. Please plan to upgrade to PostgreSQL 15 or later. +!!! warning "PostgreSQL 15 or later required" + NetBox requires PostgreSQL 15 or later. Please note that MySQL and other relational databases are **not** supported. ## Installation @@ -15,7 +12,7 @@ sudo apt update sudo apt install -y postgresql ``` -Before continuing, verify that you have installed PostgreSQL 14 or later: +Before continuing, verify that you have installed PostgreSQL 15 or later: ```no-highlight psql -V @@ -35,7 +32,6 @@ Within the shell, enter the following commands to create the database and user ( CREATE DATABASE netbox; CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K'; ALTER DATABASE netbox OWNER TO netbox; --- the next two commands are needed on PostgreSQL 15 and later \connect netbox; GRANT CREATE ON SCHEMA public TO netbox; ``` diff --git a/docs/installation/2-redis.md b/docs/installation/2-redis.md index 449de5866..dffe183fe 100644 --- a/docs/installation/2-redis.md +++ b/docs/installation/2-redis.md @@ -10,9 +10,6 @@ sudo apt install -y redis-server Before continuing, verify that your installed version of Redis is at least v6.0: -!!! warning "Redis v5.x is deprecated" - Support for Redis versions older than 6.0 is deprecated and will be removed in NetBox v4.7. - ```no-highlight redis-server -v ``` diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index 20ff6070f..79f67872a 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -1,6 +1,6 @@ -# NetBox Installation +# Install NetBox from a Release Archive or Git -This section of the documentation discusses installing and configuring the NetBox application itself. +This page covers the established release archive and Git installation methods. To install NetBox from the experimental Python package instead, follow the [separate package installation guide](3b-python-package.md). ## Install System Packages @@ -99,7 +99,7 @@ cd /opt/netbox/netbox/netbox/ sudo cp configuration_example.py configuration.py ``` -Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following four are required for new installations: +Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following five are required for new installations: * `ALLOWED_HOSTS` * `API_TOKEN_PEPPERS` diff --git a/docs/installation/3b-python-package.md b/docs/installation/3b-python-package.md new file mode 100644 index 000000000..32f45518b --- /dev/null +++ b/docs/installation/3b-python-package.md @@ -0,0 +1,361 @@ +# Install NetBox from the Python Package (Experimental) + +!!! warning "Experimental in NetBox v4.7" + Installing NetBox from the Python package is experimental in NetBox v4.7 and is **not recommended for production use**. Use this workflow to evaluate the packaged installation, test upgrades and rollback procedures, and provide feedback. + + The established [release archive and Git installation methods](3-netbox.md) remain supported and are not replaced by this workflow. + +The Python package installs the NetBox application and its Python dependencies into a virtual environment using `pip`. Configuration, uploaded media, custom scripts and reports, collected static files, and deployment configuration remain outside the installed package. + +This installation method does **not** configure PostgreSQL, Redis, a WSGI server, an HTTP server, or system services. These remain administrator-managed deployment tasks, just as they are for an archive or Git installation. + +## When to Use This Installation Method + +Use the Python package for a new test or evaluation deployment when you want `pip` to manage the NetBox application code in a dedicated virtual environment. While this workflow remains experimental, use a [release archive or Git checkout](3-netbox.md) for production deployments. + +A package installation is also available as a migration target for an existing deployment, but it is not an in-place conversion. Follow the [migration procedure](#migrate-an-existing-archive-or-git-installation) only after validating the workflow in a separate environment. + +## Understand the Installation Layout + +A package installation separates the application code from the files that belong to a particular NetBox instance. + +| Component | Example Location | Purpose | +|-----------|------------------|---------| +| Application code | `/lib/pythonX.Y/site-packages/` | Installed and replaced by `pip`; do not modify it directly | +| Python virtual environment | `/opt/netbox/venv/` | Contains NetBox, its dependencies, and any plugins | +| Instance root | `/opt/netbox/` | Holds local configuration and mutable instance data | +| Configuration | `/opt/netbox/conf/configuration.py` | Contains settings and credentials for this instance | +| Mutable data | `/opt/netbox/{media,reports,scripts,static}/` | Persists independently of package upgrades | +| Deployment examples | `/opt/netbox/contrib/` | Local copies to review and adapt before use | + +The instance root defaults to `/opt/netbox` and may be changed with the `NETBOX_ROOT` environment variable. The virtual environment does not need to be located below the instance root; `/opt/netbox/venv` is used throughout this guide only to keep the example straightforward. + +!!! note "Custom instance roots" + The `--target` option for `netbox setup` selects where the local files are created. It does not permanently set the instance root. When using a location other than `/opt/netbox`, set `NETBOX_ROOT` for all NetBox commands and services. + +## Before You Begin + +Complete the [PostgreSQL](1-postgresql.md) and [Redis](2-redis.md) installation steps first. Then install the same [required system packages](3-netbox.md#install-system-packages) used by the archive and Git installation methods. + +## Create the System User and Instance Root + +Create the `netbox` system account and the default instance root: + +```no-highlight +sudo adduser --system --group netbox +sudo mkdir -p /opt/netbox +sudo chown root:netbox /opt/netbox +sudo chmod 755 /opt/netbox +``` + +## Create the Virtual Environment + +Create a Python virtual environment and update `pip`: + +```no-highlight +sudo python3 -m venv /opt/netbox/venv +sudo /opt/netbox/venv/bin/python -m pip install --upgrade pip +``` + +Install the desired NetBox release. Replace `X.Y.Z` with the exact version to install: + +```no-highlight +sudo /opt/netbox/venv/bin/python -m pip install "netbox==X.Y.Z" +``` + +Pinning the version makes the installed release explicit and prevents an unintended upgrade when the command is repeated later. + +## Scaffold the Instance Root + +Run `netbox setup` to create the local configuration skeleton and copy the bundled deployment examples: + +```no-highlight +sudo /opt/netbox/venv/bin/netbox setup --target /opt/netbox +``` + +The command creates the following files when they do not already exist: + +```no-highlight +/opt/netbox/ +├── conf/ +│ ├── __init__.py +│ └── configuration.py +├── contrib/ +│ ├── apache.conf +│ ├── gunicorn.py +│ ├── netbox-rq.service +│ ├── netbox.env +│ ├── netbox.service +│ ├── nginx.conf +│ └── uwsgi.ini +└── local_requirements.txt +``` + +`netbox setup` is intentionally non-destructive: existing files are left untouched. It does not install systemd units, configure an HTTP server, rewrite deployment examples for the local paths, or enable plugins. + +Create the directories used for mutable instance data and grant the NetBox service account ownership of them: + +```no-highlight +sudo mkdir -p /opt/netbox/{media,reports,scripts,static} +sudo chown --recursive netbox:netbox \ + /opt/netbox/media \ + /opt/netbox/reports \ + /opt/netbox/scripts \ + /opt/netbox/static +``` + +## Configure NetBox + +Open the scaffolded configuration file: + +```no-highlight +sudo ${EDITOR:-vi} /opt/netbox/conf/configuration.py +``` + +Define the five [required configuration parameters](../configuration/required-parameters.md): + +* `ALLOWED_HOSTS` +* `API_TOKEN_PEPPERS` +* `DATABASES` +* `REDIS` +* `SECRET_KEY` + +Generate a suitable random value for `SECRET_KEY` with the installed command: + +```no-highlight +sudo /opt/netbox/venv/bin/netbox secret-key +``` + +Run the command again to generate an independent value for the first entry in `API_TOKEN_PEPPERS`. Treat both values as sensitive and do not reuse the examples from the documentation. + +After saving the configuration, restrict access while allowing the NetBox service account to read it: + +```no-highlight +sudo chown --recursive root:netbox /opt/netbox/conf +sudo chmod 750 /opt/netbox/conf +sudo chmod 640 /opt/netbox/conf/configuration.py +``` + +!!! note "Environment-based configuration" + Ensure that any environment variables referenced by `configuration.py` are present when running `netbox upgrade`, `netbox createsuperuser`, and other management commands, and provide the same variables to both NetBox services. The copied `contrib/netbox.env` file is an example only and is not loaded automatically. + +## Install Plugins and Optional Python Packages + +Plugins and any other local Python requirements must be installed into the **same virtual environment** as NetBox before running the installation or upgrade tasks. Add each package to `/opt/netbox/local_requirements.txt`, then install the file: + +```no-highlight +sudo ${EDITOR:-vi} /opt/netbox/local_requirements.txt +sudo /opt/netbox/venv/bin/python -m pip install \ + -r /opt/netbox/local_requirements.txt +``` + +Installing a plugin does not enable it. Add the plugin to the `PLUGINS` list in `/opt/netbox/conf/configuration.py` and complete any plugin-specific configuration separately. + +NetBox also provides optional package extras for several common integrations. For example, install the LDAP dependencies together with the same pinned NetBox version as follows: + +```no-highlight +sudo /opt/netbox/venv/bin/python -m pip install "netbox[ldap]==X.Y.Z" +``` + +Remember which extras are in use and specify them again when upgrading. For LDAP authentication, create `ldap_config.py` beside the active configuration file at `/opt/netbox/conf/ldap_config.py` when following the [LDAP configuration guide](6-ldap.md). Give it the same ownership and permissions as `configuration.py`: + +```no-highlight +sudo chown root:netbox /opt/netbox/conf/ldap_config.py +sudo chmod 640 /opt/netbox/conf/ldap_config.py +``` + +When using uWSGI, install `pyuwsgi` into the same virtual environment and record it as a local requirement: + +```no-highlight +sudo sh -c "echo 'pyuwsgi' >> /opt/netbox/local_requirements.txt" +sudo /opt/netbox/venv/bin/python -m pip install pyuwsgi +``` + +## Run the Installation Tasks + +Run the packaged upgrade command to apply database migrations, collect static files, and perform the remaining application installation tasks: + +```no-highlight +sudo -u netbox /opt/netbox/venv/bin/netbox upgrade --no-input +``` + +The `netbox upgrade` command is used for both a fresh package installation and future package upgrades. It replaces the source installation's `upgrade.sh` workflow. + +For a custom instance root, pass `NETBOX_ROOT` explicitly. The virtual environment may remain elsewhere: + +```no-highlight +sudo -u netbox env NETBOX_ROOT=/srv/netbox \ + /opt/netbox-venv/bin/netbox upgrade --no-input +``` + +## Create a Superuser + +Create the first administrative account: + +```no-highlight +sudo -u netbox /opt/netbox/venv/bin/netbox createsuperuser +``` + +## Test the Application + +Start Django's development server temporarily to confirm that NetBox can load its configuration and connect to its dependencies: + +```no-highlight +sudo -u netbox /opt/netbox/venv/bin/netbox \ + runserver 0.0.0.0:8000 --insecure +``` + +Connect to the server on port 8000 and log in with the superuser account. Type `Ctrl+c` to stop the development server after testing. + +!!! danger "Not for production use" + The development server is intended only for installation testing. It is neither performant nor secure enough for production use. + +## Adapt the Deployment Examples + +The files copied to `/opt/netbox/contrib/` are the same deployment examples shipped for archive and Git installations. They are not rewritten for the package layout. Adapt them before following the shared Gunicorn, uWSGI, and HTTP server instructions. + +For the default paths used in this guide, the following commands remove the source-tree references: + +```no-highlight +sudo sed -i \ + 's| --pythonpath /opt/netbox/netbox||' \ + /opt/netbox/contrib/netbox.service + +sudo sed -i \ + 's|/opt/netbox/venv/bin/python3 /opt/netbox/netbox/manage.py|/opt/netbox/venv/bin/netbox|' \ + /opt/netbox/contrib/netbox-rq.service + +sudo sed -i \ + 's|chdir = netbox|chdir = /opt/netbox|' \ + /opt/netbox/contrib/uwsgi.ini + +sudo sed -i \ + 's|/opt/netbox/netbox/static|/opt/netbox/static|g' \ + /opt/netbox/contrib/nginx.conf \ + /opt/netbox/contrib/apache.conf +``` + +These changes have the following effect: + +| File | Package Installation Change | +|------|-----------------------------| +| `netbox.service` | Imports `netbox.wsgi` from the virtual environment without a source-tree `--pythonpath` | +| `netbox-rq.service` | Runs the RQ worker through the installed `netbox` command instead of `manage.py` | +| `uwsgi.ini` | Uses the instance root rather than the absent `/opt/netbox/netbox/` source directory | +| `nginx.conf` and `apache.conf` | Serve collected static files from `/opt/netbox/static/` | + +Review every file before installing it. When using a different instance root or virtual environment, update all `WorkingDirectory`, `ExecStart`, `chdir`, virtual environment, and static-file paths accordingly. Also add the following line to the `[Service]` section of both systemd units, replacing the path as needed: + +```ini +Environment=NETBOX_ROOT=/srv/netbox +``` + +When using environment-based configuration, reference an appropriate environment file from both systemd units or define the required variables directly in each unit. + +## Continue the Installation + +With the deployment examples adapted, continue with either [Gunicorn](4a-gunicorn.md) or [uWSGI](4b-uwsgi.md). When using uWSGI and you installed `pyuwsgi` above, skip the **Installation** subsection on the uWSGI page and begin with its configuration steps. Then configure an [HTTP server](5-http-server.md) and, if needed, [LDAP authentication](6-ldap.md). + +The shared pages copy files from `/opt/netbox/contrib/`, so make the package-specific changes above **before** copying those files into their final locations. + +## Migrate an Existing Archive or Git Installation + +!!! warning "Experimental migration path" + Migrating an existing deployment to the Python package changes its filesystem and upgrade model. Take a complete backup, document the current configuration, and verify a rollback procedure before proceeding. + +Python package releases begin with NetBox v4.7. Before migrating an older deployment, first upgrade the existing archive or Git installation to a version that is available as a Python package. + +Migrate the layout separately from a NetBox version upgrade. Install the **same NetBox version** that is currently running, validate the package-based deployment, and only then upgrade to a newer release. + +The following example keeps the existing `/opt/netbox` installation in place during migration. It uses `/srv/netbox` as the new instance root and `/opt/netbox-venv` for the new virtual environment. + +1. Stop the existing NetBox services after completing a backup: + + ```no-highlight + sudo systemctl stop netbox netbox-rq + ``` + +2. Create the new virtual environment and install the same NetBox version as the existing deployment: + + ```no-highlight + sudo python3 -m venv /opt/netbox-venv + sudo /opt/netbox-venv/bin/python -m pip install --upgrade pip + sudo /opt/netbox-venv/bin/python -m pip install "netbox==X.Y.Z" + ``` + +3. Scaffold the new instance root and create its mutable directories: + + ```no-highlight + sudo mkdir -p /srv/netbox + sudo chown root:netbox /srv/netbox + sudo chmod 755 /srv/netbox + sudo /opt/netbox-venv/bin/netbox setup --target /srv/netbox + sudo mkdir -p /srv/netbox/{media,reports,scripts,static} + sudo chown --recursive netbox:netbox \ + /srv/netbox/media \ + /srv/netbox/reports \ + /srv/netbox/scripts \ + /srv/netbox/static + ``` + +4. Copy the active configuration from the existing installation. If `local_requirements.txt` exists, copy it over the empty file created by `netbox setup`: + + ```no-highlight + sudo cp /opt/netbox/netbox/netbox/configuration.py \ + /srv/netbox/conf/configuration.py + + if [ -f /opt/netbox/local_requirements.txt ]; then + sudo cp /opt/netbox/local_requirements.txt \ + /srv/netbox/local_requirements.txt + fi + ``` + + When the existing deployment uses `NETBOX_CONFIGURATION`, copy the active configuration module instead, together with any sibling modules or local files it imports. Review the copied configuration and update any filesystem paths that still reference the old source tree. + + If LDAP is configured, also copy the active `ldap_config.py` to `/srv/netbox/conf/ldap_config.py`. + +5. Copy locally stored media, reports, and scripts. Do not copy collected static files; `netbox upgrade` will create them again. + + ```no-highlight + sudo cp -a /opt/netbox/netbox/media/. /srv/netbox/media/ + sudo cp -a /opt/netbox/netbox/reports/. /srv/netbox/reports/ + sudo cp -a /opt/netbox/netbox/scripts/. /srv/netbox/scripts/ + sudo chown --recursive netbox:netbox \ + /srv/netbox/media \ + /srv/netbox/reports \ + /srv/netbox/scripts + ``` + + Use the paths configured by `MEDIA_ROOT`, `REPORTS_ROOT`, and `SCRIPTS_ROOT` instead when the existing deployment stores these files elsewhere. + +6. Install all plugins and local requirements into the new virtual environment **before** running the upgrade tasks: + + ```no-highlight + sudo /opt/netbox-venv/bin/python -m pip install \ + -r /srv/netbox/local_requirements.txt + ``` + + Repeat any NetBox package extras used by the deployment, and verify that each plugin supports the installed NetBox version. + +7. Secure the configuration and run the package installation tasks against the existing database: + + ```no-highlight + sudo chown --recursive root:netbox /srv/netbox/conf + sudo chmod 750 /srv/netbox/conf + sudo chmod 640 /srv/netbox/conf/configuration.py + + sudo -u netbox env NETBOX_ROOT=/srv/netbox \ + /opt/netbox-venv/bin/netbox upgrade --no-input + ``` + + If `ldap_config.py` was copied, also run `sudo chmod 640 /srv/netbox/conf/ldap_config.py`. + +8. Follow [Adapt the Deployment Examples](#adapt-the-deployment-examples), substituting `/srv/netbox` and `/opt/netbox-venv` for the example paths. Install the updated systemd and HTTP server configuration, switch the services to the package deployment, and ensure that both systemd units define `NETBOX_ROOT=/srv/netbox`. + +9. Start the services, test the web interface and background processing, and retain the previous installation until the new deployment has been validated: + + ```no-highlight + sudo systemctl start netbox netbox-rq + ``` + +After the migration is complete, use the [Python package upgrade procedure](upgrading.md#upgrade-a-python-package-installation-experimental) for future releases. diff --git a/docs/installation/6-ldap.md b/docs/installation/6-ldap.md index 4d27f2f01..d8f6f007b 100644 --- a/docs/installation/6-ldap.md +++ b/docs/installation/6-ldap.md @@ -12,18 +12,30 @@ sudo apt install -y libldap2-dev libsasl2-dev libssl-dev ### Install django-auth-ldap -Activate the Python virtual environment and install the `django-auth-ldap` package using pip: +=== "Release archive or Git" -```no-highlight -source /opt/netbox/venv/bin/activate -pip3 install django-auth-ldap -``` + Activate the Python virtual environment and install the `django-auth-ldap` package using pip: -Once installed, add the package to `local_requirements.txt` to ensure it is re-installed during future rebuilds of the virtual environment: + ```no-highlight + source /opt/netbox/venv/bin/activate + pip3 install django-auth-ldap + ``` -```no-highlight -sudo sh -c "echo 'django-auth-ldap' >> /opt/netbox/local_requirements.txt" -``` + Once installed, add the package to `local_requirements.txt` to ensure it is re-installed during future rebuilds of the virtual environment: + + ```no-highlight + sudo sh -c "echo 'django-auth-ldap' >> /opt/netbox/local_requirements.txt" + ``` + +=== "Python package (experimental)" + + Install NetBox's `ldap` optional dependency group, pinned to the installed NetBox version: + + ```no-highlight + sudo /opt/netbox/venv/bin/python -m pip install "netbox[ldap]==X.Y.Z" + ``` + + Specify the `ldap` extra again when upgrading the NetBox package. See the [Python package upgrade procedure](upgrading.md#upgrade-a-python-package-installation-experimental). ## Configuration @@ -33,7 +45,14 @@ First, enable the LDAP authentication backend in `configuration.py`. (Be sure to REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend' ``` -Next, create a file in the same directory as `configuration.py` (typically `/opt/netbox/netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](https://django-auth-ldap.readthedocs.io/). +Next, create a file named `ldap_config.py` in the same directory as the active `configuration.py`. This is typically `/opt/netbox/netbox/netbox/` for a release archive or Git installation, or `/opt/netbox/conf/` for a Python package installation. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](https://django-auth-ldap.readthedocs.io/). + +For a Python package installation, protect the file while allowing the NetBox service account to read it: + +```no-highlight +sudo chown root:netbox /opt/netbox/conf/ldap_config.py +sudo chmod 640 /opt/netbox/conf/ldap_config.py +``` ### General Server Configuration diff --git a/docs/installation/index.md b/docs/installation/index.md index 38f5b1d76..b8ea97cf1 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -18,21 +18,23 @@ The following sections detail how to set up a new instance of NetBox: 1. [PostgreSQL database](1-postgresql.md) 2. [Redis](2-redis.md) -3. [NetBox components](3-netbox.md) +3. Install the NetBox application using either: + * a [release archive or Git checkout](3-netbox.md); or + * the [Python package](3b-python-package.md) (experimental) 4. [Gunicorn](4a-gunicorn.md) or [uWSGI](4b-uwsgi.md) 5. [HTTP server](5-http-server.md) 6. [LDAP authentication](6-ldap.md) (optional) +!!! warning "Experimental Python package installation" + Installing NetBox from the Python package is experimental in NetBox v4.7 and is not recommended for production use. It is intended for evaluation and feedback. The release archive and Git workflows remain supported and are the established installation methods. + ## Requirements | Dependency | Supported Versions | |------------|--------------------| | Python | 3.12, 3.13, 3.14 | -| PostgreSQL | 14+ [^1] | -| Redis | 5.0+ [^2] | - -[^1]: Support for PostgreSQL 14 is deprecated and will be removed in NetBox v4.7. PostgreSQL 15 or later will be required. -[^2]: Support for Redis versions older than 6.0 is deprecated and will be removed in NetBox v4.7. Redis 6.0 or later will be required. +| PostgreSQL | 15+ | +| Redis | 6.0+ | Below is a simplified overview of the NetBox application stack for reference: diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index cc0ec476f..7db5e27cb 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -22,26 +22,27 @@ block-beta !!! warning "Perform a Backup" Always be sure to save a backup of your current NetBox deployment prior to starting the upgrade process. -## 1. Review the Release Notes +## Review the Release Notes Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../release-notes/index.md) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the release in which the change went into effect. -## 2. Update Dependencies to Required Versions +Before proceeding, verify that all installed plugins support the target NetBox release. + +## Update Required Dependencies NetBox requires the following dependencies: | Dependency | Supported Versions | |------------|--------------------| | Python | 3.12, 3.13, 3.14 | -| PostgreSQL | 14+ [^1] | -| Redis | 5.0+ | - -[^1]: Support for PostgreSQL 14 is deprecated and will be removed in NetBox v4.7. PostgreSQL 15 or later will be required. +| PostgreSQL | 15+ | +| Redis | 6.0+ | ### Version History | NetBox Version | Python min | Python max | PostgreSQL min | Redis min | Documentation | |:--------------:|:----------:|:----------:|:--------------:|:---------:|:-----------------------------------------------------------------------------------------:| +| 4.7 | 3.12 | 3.14 | 15 | 6.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.7.0/docs/installation/index.md) | | 4.6 | 3.12 | 3.14 | 14 | 5.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.6.0/docs/installation/index.md) | | 4.5 | 3.12 | 3.14 | 14 | 5.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.5.0/docs/installation/index.md) | | 4.4 | 3.10 | 3.12 | 14 | 5.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.4.0/docs/installation/index.md) | @@ -58,7 +59,36 @@ NetBox requires the following dependencies: | 3.1 | 3.7 | 3.9 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.1.0/docs/installation/index.md) | | 3.0 | 3.7 | 3.9 | 9.6 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.0.0/docs/installation/index.md) | -## 3. Install the Latest Release +## Verify Database Permissions + +NetBox v4.7 and later require the PostgreSQL [`ltree` extension](https://www.postgresql.org/docs/current/ltree.html). NetBox installs this extension automatically when applying database migrations if it is not already present. Installing it requires that the NetBox database user hold the `CREATE` privilege on the database. + +!!! note + Installations created using NetBox's PostgreSQL setup instructions already satisfy this requirement because those instructions make the NetBox user the database owner. No additional grant is needed for these installations. + +If `ltree` is not already installed and the NetBox database user does not hold the `CREATE` privilege, grant it by invoking the PostgreSQL shell as the system Postgres user: + +```no-highlight +sudo -u postgres psql +``` + +Then issue the following command, substituting the name of your database and user (role) where applicable: + +```postgresql +GRANT CREATE ON DATABASE netbox TO netbox; +``` + +Alternatively, a database administrator can install the extension before upgrading: + +```postgresql +CREATE EXTENSION IF NOT EXISTS ltree; +``` + +## Upgrade a Release Archive or Git Installation + +The following procedure applies to NetBox installations created from a release archive or Git checkout. Complete the preparation steps above, then use the same installation method that was used for the existing deployment. + +### 1. Install the Latest Release As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by checking out the latest production release from the git repository. @@ -73,7 +103,7 @@ ls -ld /opt/netbox /opt/netbox/.git If NetBox was installed from a release package, then `/opt/netbox` will be a symlink pointing to the current version, and `/opt/netbox/.git` will not exist. If it was installed from git, then `/opt/netbox` and `/opt/netbox/.git` will both exist as normal directories. -### Option A: Download a Release +#### Option A: Download a Release Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive. Extract it to your desired path. In this example, we'll use `/opt/netbox`. @@ -116,7 +146,7 @@ If you followed the original installation guide to set up gunicorn, be sure to c sudo cp /opt/netbox-$OLDVER/gunicorn.py /opt/netbox/ ``` -### Option B: Check Out a Git Release +#### Option B: Check Out a Git Release This guide assumes that NetBox is installed in `/opt/netbox`. First, determine the latest release either by visiting our [releases page](https://github.com/netbox-community/netbox/releases) or by running the following command: @@ -135,7 +165,7 @@ sudo git fetch --tags && \ sudo git checkout v4.5.0 ``` -## 4. Run the Upgrade Script +### 2. Run the Upgrade Script Once the new code is in place, verify that any optional Python packages required by your deployment (e.g. `django-auth-ldap`) are listed in `local_requirements.txt`. Then, run the upgrade script: @@ -169,7 +199,7 @@ This script performs the following actions: been made to your local codebase and should be investigated. Never attempt to create new migrations unless you are intentionally modifying the database schema. -## 5. Restart the NetBox Services +### 3. Restart the NetBox Services !!! warning If you are upgrading from an installation that does not use a Python virtual environment (any release prior to v2.7.9), you'll need to update the systemd service files to reference the new Python and gunicorn executables before restarting the services. These are located in `/opt/netbox/venv/bin/`. See the example service files in `/opt/netbox/contrib/` for reference. @@ -179,3 +209,83 @@ Finally, restart the gunicorn and RQ services: ```no-highlight sudo systemctl restart netbox netbox-rq ``` + +## Upgrade a Python Package Installation (Experimental) + +!!! warning "Experimental installation method" + Installing NetBox from the Python package is experimental in NetBox v4.7 and is **not recommended for production use**. Test the upgrade and rollback procedures in a non-production environment before relying on them. + +This procedure applies only to a deployment created using the [Python package installation method](3b-python-package.md). A package installation does not use `upgrade.sh`; use the installed `netbox upgrade` command instead. For a release archive or Git installation, follow the [procedure above](#upgrade-a-release-archive-or-git-installation). + +Complete the preparation steps at the beginning of this page before proceeding. + +### 1. Stop the NetBox Services + +Stop the web application and background worker services before changing packages in the virtual environment: + +```no-highlight +sudo systemctl stop netbox netbox-rq +``` + +### 2. Upgrade NetBox and Local Requirements + +Install the target NetBox version into the existing virtual environment. Replace `X.Y.Z` with the exact version being installed: + +```no-highlight +sudo /opt/netbox/venv/bin/python -m pip install --upgrade "netbox==X.Y.Z" +``` + +If the deployment uses a package extra, include it in the upgrade command. For example, specify the `ldap` extra again when upgrading a deployment that uses LDAP authentication: + +```no-highlight +sudo /opt/netbox/venv/bin/python -m pip install --upgrade \ + "netbox[ldap]==X.Y.Z" +``` + +Install all plugins and other local Python requirements into the same virtual environment **before** running the NetBox upgrade tasks: + +```no-highlight +sudo /opt/netbox/venv/bin/python -m pip install \ + -r /opt/netbox/local_requirements.txt +``` + +!!! note "Changing the Python version" + A virtual environment cannot be moved to a different Python interpreter in place. If the target NetBox release requires another Python version, create a replacement virtual environment, install the target NetBox package and all local requirements into it, and update the service executable paths before restarting NetBox. + +### 3. Run the Upgrade Tasks + +Run the packaged upgrade command to apply database migrations, collect static files, and perform the remaining application upgrade tasks: + +```no-highlight +sudo -u netbox /opt/netbox/venv/bin/netbox upgrade --no-input +``` + +For a non-default instance root or a virtual environment stored elsewhere, use the applicable paths and set `NETBOX_ROOT` explicitly: + +```no-highlight +sudo -u netbox env NETBOX_ROOT=/srv/netbox \ + /opt/netbox-venv/bin/netbox upgrade --no-input +``` + +Ensure that any environment variables referenced by the NetBox configuration are also available when running this command. + +### 4. Review the Deployment Configuration + +`netbox setup` is not part of a routine upgrade. It leaves existing configuration and deployment examples untouched. To compare the examples bundled with the new package against the local copies without modifying the instance root, scaffold them into a temporary directory: + +```no-highlight +EXAMPLES_DIR=$(mktemp -d) +/opt/netbox/venv/bin/netbox setup --target "$EXAMPLES_DIR" +diff --recursive /opt/netbox/contrib "$EXAMPLES_DIR/contrib" +rm -rf "$EXAMPLES_DIR" +``` + +The comparison will also show the package-layout changes made when the deployment examples were first adapted. Distinguish these local changes from updates introduced by the new release, and merge any relevant updates into the administrator-managed systemd, WSGI, and HTTP server configuration. + +### 5. Start the NetBox Services + +Start the services and verify that both the web application and background workers are operating normally: + +```no-highlight +sudo systemctl start netbox netbox-rq +``` diff --git a/docs/integrations/graphql-api.md b/docs/integrations/graphql-api.md index a7f05ee0f..abb1dc12c 100644 --- a/docs/integrations/graphql-api.md +++ b/docs/integrations/graphql-api.md @@ -51,9 +51,6 @@ For more detail on constructing GraphQL queries, see the [GraphQL queries docume ## Filtering -!!! note "Changed in NetBox v4.3" - The filtering syntax fo the GraphQL API has changed substantially in NetBox v4.3. - Filters can be specified as key-value pairs within parentheses immediately following the query name. For example, the following will return only active sites: ``` @@ -139,8 +136,6 @@ The alternative approach is cursor-based pagination, which operates using absolu To ensure consistent ordering, objects will always be ordered by their primary keys when cursor-based pagination is used. -!!! note "Cursor-based pagination was introduced in NetBox v4.5.2." - Both pagination strategies support an optional `limit` parameter specifying the maximum number of objects to include in the response. The [`MAX_PAGE_SIZE`](../configuration/miscellaneous.md#max_page_size) configuration parameter (default `1000`) sets a hard ceiling on this value; if no limit is specified, up to `MAX_PAGE_SIZE` records are returned. When `MAX_PAGE_SIZE` is set to `0` or `None`: diff --git a/docs/integrations/rest-api.md b/docs/integrations/rest-api.md index b7fcbd888..e3f4e98b4 100644 --- a/docs/integrations/rest-api.md +++ b/docs/integrations/rest-api.md @@ -253,8 +253,6 @@ Similarly, you can opt to omit only specific fields by passing the `omit` parame GET /api/dcim/sites/?omit=circuit_count,device_count,virtualmachine_count ``` -!!! note "The `omit` parameter was introduced in NetBox v4.5.2." - Strategic use of the `fields` and `omit` parameters can drastically improve REST API performance, as the exclusion of fields which reference related objects reduces the number and complexity of underlying database queries needed to generate the response. !!! note @@ -338,10 +336,6 @@ GET /api/ipam/prefixes/13980/?brief=true The brief format is supported for both lists and individual objects. -### Excluding Config Contexts - -When retrieving devices and virtual machines via the REST API, each will include its rendered [configuration context data](../features/context-data.md) by default. Users with large amounts of context data will likely observe suboptimal performance when returning multiple objects, particularly with very high page sizes. To combat this, context data may be excluded from the response data by attaching the query parameter `?exclude=config_context` to the request. This parameter works for both list and detail views. - ## Pagination API responses which contain a list of many objects will be paginated for efficiency. NetBox employs offset-based pagination by default, which forms a page by skipping the number of objects indicated by the `offset` URL parameter. The root JSON object returned by a list endpoint contains the following attributes: @@ -592,6 +586,9 @@ http://netbox/api/dcim/sites/ \ ] ``` +!!! note + The bulk creation of objects is an all-or-none operation, meaning that if NetBox fails to successfully create any of the specified objects (e.g. due to a validation error), the entire operation will be aborted and none of the objects will be created. + ### Updating an Object To modify an object which has already been created, make a `PATCH` request to the model's _detail_ endpoint specifying its unique numeric ID. Include any data which you wish to update on the object. As with object creation, the `Authorization` and `Content-Type` headers must also be specified. @@ -666,9 +663,27 @@ Note that there is no requirement for the attributes to be identical among objec !!! note The bulk update of objects is an all-or-none operation, meaning that if NetBox fails to successfully update any of the specified objects (e.g. due a validation error), the entire operation will be aborted and none of the objects will be updated. -### Concurrent Update Protection +### Errors in Bulk Operations -!!! info "This feature was introduced in NetBox v4.6." +!!! info "This feature was introduced in NetBox v4.7." + +When a bulk creation or update fails validation, the response identifies each offending object by its index within the submitted list, so that a client can correct and resubmit only the objects which actually failed. (The operation itself remains all-or-none: No objects are written unless every object validates.) + +```json +{ + "detail": "1 of 3 objects failed validation.", + "errors": [ + { + "index": 1, + "errors": { + "slug": ["This field may not be blank."] + } + } + ] +} +``` + +### Concurrent Update Protection To guard against the lost-update problem when multiple clients modify the same object, NetBox returns a weak `ETag` response header on detail-view responses (`GET`, `POST`, `PATCH`, `PUT`) for individual objects. Clients may supply this value back on a subsequent `PATCH` or `PUT` request via the `If-Match` request header. If the object's current ETag does not match any of the values supplied, the server rejects the request with a `412 Precondition Failed` response and includes the current ETag in the response so the client can retry. @@ -690,8 +705,6 @@ A literal `If-Match: *` value matches any current ETag and may be used to assert ### Adding and Removing Tags -!!! info "This feature was introduced in NetBox v4.6." - In addition to replacing an object's tag set wholesale via the `tags` field, taggable models accept two write-only fields, `add_tags` and `remove_tags`, which apply only the specified additions or removals without disturbing existing tags. This is convenient when concurrent clients each manage a distinct subset of an object's tags. ```no-highlight @@ -741,6 +754,53 @@ http://netbox/api/dcim/sites/ \ !!! note The bulk deletion of objects is an all-or-none operation, meaning that if NetBox fails to delete any of the specified objects (e.g. due a dependency by a related object), the entire operation will be aborted and none of the objects will be deleted. +## Background Processing + +!!! info "This feature was introduced in NetBox v4.7." + +Bulk write operations (creating, updating, or deleting multiple objects via a model's list endpoint) can optionally be processed as a [background job](../features/background-jobs.md) rather than synchronously. This is useful for large batches that would otherwise hold the connection open long enough to risk a proxy or gateway timeout. + +To request background processing, append the `background=true` query parameter to a bulk write request. NetBox enqueues a job and returns an `HTTP 202 Accepted` response containing the job's ID and URL. The actual write is performed later by a worker, running the same logic (and preserving the same all-or-none transaction semantics) as the synchronous path. Note that the request payload is **not** validated before the job is enqueued; validation is deferred to the worker (see below). + +```no-highlight +curl -s -X PATCH \ +-H "Authorization: Token $TOKEN" \ +-H "Content-Type: application/json" \ +http://netbox/api/dcim/sites/?background=true \ +--data '[{"id": 10, "status": "active"}, {"id": 11, "status": "active"}]' +``` + +The response identifies the enqueued job: + +```json +{ + "job": { + "id": 42, + "url": "http://netbox/api/core/jobs/42/", + "status": "pending" + } +} +``` + +Poll the job's URL to track its progress. When the job reaches a terminal status, its `data` field holds the result and its `error` field describes any failure. The `data` field mirrors the response the synchronous request would have returned, as an object with the HTTP `status_code` and the response `data`. For example, a completed bulk update records: + +```json +{ + "status_code": 200, + "data": [ + {"id": 10, "url": "http://netbox/api/dcim/sites/10/", "status": {"value": "active"}, "...": "..."} + ] +} +``` + +A failed job records the equivalent error response, for instance `{"status_code": 400, "data": {"slug": ["This field may not be blank."]}}`, with a short summary also placed in the job's `error` field. + +A `202` response indicates that the request was accepted and queued, not that it succeeded: validation (including malformed or invalid payloads) and the database write all occur when the job runs. A rejected payload is therefore reported as a failed job rather than a synchronous error response. Always inspect the job's final status to confirm the outcome. Because the result is stored on the job, any user permitted to view jobs (`core.view_job`, subject to object permissions) can read the serialized objects it contains. + +Background processing applies only to bulk operations (a JSON list) on a model's list endpoint. For a single-object write the `background` parameter is ignored and the request is processed synchronously. It cannot be combined with an [`If-Match`](#if-match) precondition (which cannot be evaluated reliably once execution is deferred); such a request is rejected with an `HTTP 400` response. If no background worker is running to service the queue, the request is rejected with an `HTTP 503` response rather than enqueuing a job that would never run. + +Two behaviors differ from a synchronous request and may change in a future release: field selection via [`fields`/`omit`](#specifying-fields) (and brief mode) is not applied to the stored result, and the authorization captured when the request is accepted is not re-checked if the token is later disabled or expires before the job runs. + ## Changelog Messages Most objects in NetBox support [change logging](../features/change-logging.md), which generates a detailed record each time an object is created, modified, or deleted. Additionally, users can attach a message to the change record as well. This is accomplished via the REST API by including a `changelog_message` field in the object representation. @@ -784,7 +844,7 @@ The NetBox REST API primarily employs token-based authentication. For convenienc ### Tokens -A token is a secret, unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile. When creating a token, NetBox will automatically populate a randomly-generated token value. +A token is a secret, unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile. When creating a token, NetBox will automatically generate a random token value. This value is always generated by the server and cannot be specified by the client; any `token` value included in a creation request is ignored. !!! note "Tokens cannot be retrieved once created" Once a token has been created, its plaintext value cannot be retrieved. For this reason, you must take care to securely record the token locally immediately upon its creation. If a token plaintext is lost, it cannot be recovered: A new token must be created. diff --git a/docs/integrations/webhooks.md b/docs/integrations/webhooks.md index 7f9983da6..bcbd05350 100644 --- a/docs/integrations/webhooks.md +++ b/docs/integrations/webhooks.md @@ -17,7 +17,7 @@ For example, you might create a NetBox webhook to [trigger a Slack message](http * HTTP method: `POST` * URL: Slack incoming webhook URL * HTTP content type: `application/json` -* Body template: `{"text": "IP address {{ data['address'] }} was created by {{ username }}!"}` +* Body template: `{"text": "IP address {{ data['address'] }} was created by {{ request.user }}!"}` ### Available Context @@ -30,16 +30,11 @@ The following data is available as context for Jinja2 templates: * `request.id` - The UUID associated with the request * `request.method` - The HTTP method (e.g. `GET` or `POST`) * `request.path` - The URL path (ex: `/dcim/sites/123/edit/`) + * `request.path_info` - The URL path below the application script prefix + * `request.GET` - The query parameters included in the request * `request.user` - The name of the authenticated user who made the request (if available) * `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API. * `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided as a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed. -* ⚠️ `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request. -* ⚠️ `username` - The name of the user account associated with the change. - -!!! warning "Deprecation of legacy keys" - The `request_id` and `username` keys in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0. - - Use `request.user` and `request.id` from the `request` object included in the callback context instead. ### Sanitizing Header Values @@ -60,8 +55,6 @@ If no body template is specified, the request body will be populated with a JSON "event": "created", "timestamp": "2026-03-06T15:11:23.503186+00:00", "object_type": "dcim.site", - "username": "jstretch", - "request_id": "17af32f0-852a-46ca-a7d4-33ecd0c13de6", "data": { "id": 4, "url": "/api/dcim/sites/4/", diff --git a/docs/introduction.md b/docs/introduction.md index fee89559a..1b7cf5316 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -79,7 +79,5 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and | HTTP service | nginx or Apache | | WSGI service | gunicorn or uWSGI | | Application | Django/Python | -| Database | PostgreSQL 14+ [^1] | +| Database | PostgreSQL 15+ | | Task queuing | Redis/django-rq | - -[^1]: Support for PostgreSQL 14 is deprecated and will be removed in NetBox v4.7. PostgreSQL 15 or later will be required. diff --git a/docs/models/core/job.md b/docs/models/core/job.md index 05ed53024..43ba0d660 100644 --- a/docs/models/core/job.md +++ b/docs/models/core/job.md @@ -28,6 +28,10 @@ The interval (in minutes) at which a scheduled job should re-execute. The date and time at which the job completed (if complete). +### Execution Time + +The amount of time the job spent executing, calculated as the difference between its start and completion times. This is populated only once a started job has completed. + ### User The user who created the job. diff --git a/docs/models/dcim/cable.md b/docs/models/dcim/cable.md index 8a60d8353..f72493848 100644 --- a/docs/models/dcim/cable.md +++ b/docs/models/dcim/cable.md @@ -23,8 +23,6 @@ The cable's operational status. Choices include: ### Profile -!!! note "This field was introduced in NetBox v4.5." - The profile to which the cable conforms. The profile determines the mapping of termination between the two ends and enables logical tracing across complex connections, such as breakout cables. Supported profiles are listed below. * Straight (single position) @@ -34,7 +32,9 @@ The profile to which the cable conforms. The profile determines the mapping of t A single-position cable is allowed only one termination point at each end. There is no limit to the number of terminations a multi-position cable may have. Each end of a cable must have the same number of terminations, unless connected to a pass-through port or to a circuit termination. -The assignment of a cable profile is optional. If no profile is assigned, legacy tracing behavior will be preserved. +The assignment of a cable profile is optional. If no profile is assigned, legacy tracing behavior will be preserved. Note that a cable's profile is what maps each termination to a connector and position: a cable carrying multiple terminations on an end but having no profile assigned is permitted, but NetBox cannot map its positions across the cable. Assign a profile to model a breakout cable whose individual positions must be traced. + +When creating cables in bulk, each side accepts a comma-separated list of termination names, along with either a single parent device (or power panel) shared by all of them or one parent per name. Terminations are assigned to connectors in the order given, so the order of these lists determines how the cable is wired. ### Type diff --git a/docs/models/dcim/coolingfeed.md b/docs/models/dcim/coolingfeed.md new file mode 100644 index 000000000..e0d3906d5 --- /dev/null +++ b/docs/models/dcim/coolingfeed.md @@ -0,0 +1,37 @@ +# Cooling Feed + +A cooling feed represents a coolant loop delivered from a [cooling source](./coolingsource.md) to a particular rack or coolant distribution unit (CDU). The [cooling intakes](./coolingintake.md) a feed supplies are derived from the devices installed in the rack it serves, rather than referenced explicitly. + +A single feed represents the entire loop, covering both the supply (cold) and return (warm) paths. + +!!! tip + In-rack cooling equipment — coolant distribution units (CDUs), manifolds, and rear-door heat exchangers (RDHx) — is modeled as an ordinary (typically zero-U) [device](./device.md) installed in the rack. The device's make and model come from its [device type](./devicetype.md), and a [cooling intake](./coolingintake.md) component connects it to cooling. The feed serving such a device is derived from its rack. + +## Fields + +### Cooling Source + +The [cooling source](./coolingsource.md) which supplies this feed. + +### Rack + +The [rack](./rack.md) which this feed serves (optional). + +### Name + +The feed's name or identifier. Must be unique to the assigned cooling source. + +### Status + +The feed's operational status. + +!!! tip + Additional statuses may be defined by setting `CoolingFeed.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. + +### Cooling Capacity + +The heat-removal capacity of the feed, in kilowatts (kW). + +### Maximum Flow + +The maximum rate of coolant flow supported by the feed, expressed as a numeric value with a selectable unit (liters per minute, cubic meters per hour, or gallons per minute). Must be a positive, non-zero value in the selected unit, or left blank. diff --git a/docs/models/dcim/coolingintake.md b/docs/models/dcim/coolingintake.md new file mode 100644 index 000000000..f7bae697a --- /dev/null +++ b/docs/models/dcim/coolingintake.md @@ -0,0 +1,40 @@ +# Cooling Intakes + +A cooling intake is a device component which consumes coolant, such as a server cold-plate inlet or a coolant distribution unit (CDU) intake. It **receives** coolant from the cold, supply side of a loop (see [cooling](../../features/cooling.md) for the overall flow model). A cooling intake optionally references the upstream [cooling outflow](./coolingoutflow.md) which supplies it. + +!!! tip + Like most device components, cooling intakes are instantiated automatically from [cooling intake templates](./coolingintaketemplate.md) assigned to the selected device type when a device is created. + +## Fields + +### Device + +The device to which this cooling intake belongs. + +### Module + +The installed module within the assigned device to which this cooling intake belongs (optional). + +### Name + +The name of the cooling intake. Must be unique to the parent device. + +### Label + +An alternative physical label identifying the cooling intake. + +### Connector Type + +The physical coolant connector type (e.g. UQD, UQDB, QDC, camlock, or threaded NPT/BSP). + +### Diameter + +The connector diameter, expressed as a numeric value with a selectable unit (millimeters, centimeters, or inches). Must be a positive, non-zero value in the selected unit, or left blank. + +### Maximum Flow + +The maximum coolant flow rate this port supports, expressed as a numeric value with a selectable unit (liters per minute, cubic meters per hour, or gallons per minute). Must be a positive, non-zero value in the selected unit, or left blank. + +### Cooling Outflow + +The upstream [cooling outflow](./coolingoutflow.md) which supplies this intake (optional). diff --git a/docs/models/dcim/coolingintaketemplate.md b/docs/models/dcim/coolingintaketemplate.md new file mode 100644 index 000000000..437a6cf5a --- /dev/null +++ b/docs/models/dcim/coolingintaketemplate.md @@ -0,0 +1,3 @@ +# Cooling Intake Templates + +A template for a cooling intake that will be created on all instantiations of the parent device type. See the [cooling intake](./coolingintake.md) documentation for more detail. diff --git a/docs/models/dcim/coolingoutflow.md b/docs/models/dcim/coolingoutflow.md new file mode 100644 index 000000000..46684efdb --- /dev/null +++ b/docs/models/dcim/coolingoutflow.md @@ -0,0 +1,38 @@ +# Cooling Outflows + +A cooling outflow is a device component which delivers coolant to a downstream [cooling intake](./coolingintake.md), and generally represents an outlet on a coolant distribution unit (CDU) or manifold. A cooling outflow may optionally be associated with an upstream cooling intake on the same device for path tracing. + +A cooling outflow is a **supply** point on the cold, coolant-distribution side of a loop: it passes coolant onward to downstream equipment. It does **not** represent the return of warmed coolant back to the cooling source. The return path is not modeled per-component; instead, a single [cooling feed](./coolingfeed.md) represents the entire loop, covering both the supply (cold) and return (warm) paths. + +!!! tip + Like most device components, cooling outflows are instantiated automatically from [cooling outflow templates](./coolingoutflowtemplate.md) assigned to the selected device type when a device is created. + +## Fields + +### Device + +The device to which this cooling outflow belongs. + +### Module + +The installed module within the assigned device to which this cooling outflow belongs (optional). + +### Name + +The name of the cooling outflow. Must be unique to the parent device. + +### Label + +An alternative physical label identifying the cooling outflow. + +### Connector Type + +The physical coolant connector type (e.g. UQD, UQDB, QDC, camlock, or threaded NPT/BSP). + +### Diameter + +The connector diameter, expressed as a numeric value with a selectable unit (millimeters, centimeters, or inches). Must be a positive, non-zero value in the selected unit, or left blank. + +### Cooling Intake + +The upstream [cooling intake](./coolingintake.md) on the same device which feeds this outlet (optional). diff --git a/docs/models/dcim/coolingoutflowtemplate.md b/docs/models/dcim/coolingoutflowtemplate.md new file mode 100644 index 000000000..9f4e0cb80 --- /dev/null +++ b/docs/models/dcim/coolingoutflowtemplate.md @@ -0,0 +1,3 @@ +# Cooling Outflow Templates + +A template for a cooling outflow that will be created on all instantiations of the parent device type. See the [cooling outflow](./coolingoutflow.md) documentation for more detail. diff --git a/docs/models/dcim/coolingsource.md b/docs/models/dcim/coolingsource.md new file mode 100644 index 000000000..b795f7bff --- /dev/null +++ b/docs/models/dcim/coolingsource.md @@ -0,0 +1,36 @@ +# Cooling Source + +A cooling source represents a facility-level source of cooling, such as a chiller, cooling tower, or dry cooler. It serves as the upstream origin for one or more [cooling feeds](./coolingfeed.md) which distribute coolant to racks and devices. A cooling source is not modeled as a device; it represents external facility plant. + +## Fields + +### Site + +The [site](./site.md) at which the cooling source is located. + +### Location + +The [location](./location.md) within the site where the cooling source resides (optional). + +### Name + +The cooling source's name or identifier. Must be unique to the assigned site. + +### Type + +The type of cooling plant (e.g. chiller, cooling tower, dry cooler, CRAC, or CRAH). + +### Status + +The operational status of the cooling source. + +!!! tip + Additional statuses may be defined by setting `CoolingSource.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. + +### Fluid Type + +The coolant used by the source (e.g. water, water/glycol, dielectric fluid, or refrigerant). + +### Cooling Capacity + +The total heat-removal capacity of the source, expressed in kilowatts (kW). diff --git a/docs/models/dcim/device.md b/docs/models/dcim/device.md index 8b38d7c89..ed6a0d2cc 100644 --- a/docs/models/dcim/device.md +++ b/docs/models/dcim/device.md @@ -30,6 +30,10 @@ The hardware [device type](./devicetype.md) which defines the device's make & mo The direction in which air circulates through the device chassis for cooling. +### Cooling Method + +The cooling method employed by the device (air, liquid, hybrid, or immersion). If not set, this is inherited from the assigned [device type](./devicetype.md) when the device is created. + ### Serial Number The unique physical serial number assigned to this device by its manufacturer. diff --git a/docs/models/dcim/devicetype.md b/docs/models/dcim/devicetype.md index 4f75aff09..4ed6ba875 100644 --- a/docs/models/dcim/devicetype.md +++ b/docs/models/dcim/devicetype.md @@ -57,10 +57,18 @@ Indicates whether this is a parent type (capable of housing child devices), a ch The default direction in which airflow circulates within the device chassis. This may be configured differently for instantiated devices (e.g. because of different fan modules). +### Cooling Method + +The default cooling method employed by devices of this type (air, liquid, hybrid, or immersion). Instantiated devices inherit this value unless overridden. + ### Weight The numeric weight of the device, including a unit designation (e.g. 10 kilograms or 20 pounds). +### End of Life + +The date after which this device type is no longer supported by its manufacturer. This can be used to identify devices approaching or past their support horizon to aid in hardware lifecycle planning. + ### Front & Rear Images Users can upload illustrations of the device's front and rear panels. If present, these will be used to render the device in [rack](./rack.md) elevation diagrams. diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md index 7f67e4e7a..1f1741882 100644 --- a/docs/models/dcim/interface.md +++ b/docs/models/dcim/interface.md @@ -28,11 +28,19 @@ An alternative physical label identifying the interface. ### Type -The type of interface. Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. +The type of interface. Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. The generic **channel** type identifies a [channelized subinterface](#channel-id) bound to a parent interface when its specific transceiver type is not relevant; a channel subinterface may instead keep its own specific physical type (e.g. directly declaring a channel as 10GBASE-SR) to record the actual transceiver in use. !!! note The interface type refers to the physical termination or port on the device. Interfaces which employ a removable optic or similar transceiver should be defined to represent the type of transceiver in use, irrespective of the physical termination to that transceiver. +### Channels + +!!! info "This field was added in NetBox v4.7." + +For a channelized (breakout) interface, the number of physical channels into which the interface is divided. For example, a 40GE interface broken out into four 10GE channels would have `channels` set to four. Each channel is modeled as a channel subinterface bound to this interface via its [channel ID](#channel-id). + +A single physical cable terminates to the channelized (parent) interface, occupying one connector shared by all of its channels; NetBox traces a distinct cable path for each channel subinterface. Only one layer of channelization is supported: an interface cannot be both channelized and itself bound to a channel. + ### Speed The operating speed, in kilobits per second (kbps). @@ -49,7 +57,7 @@ The [virtual routing and forwarding](../ipam/vrf.md) instance to which this inte The [MAC address](./macaddress.md) assigned to this interface which is designated as its primary. -!!! note "Changed in NetBox v4.2" +!!! note "MAC address is a property" The MAC address of an interface (formerly a concrete database field) is available as a property, `mac_address`, which reflects the value of the primary linked [MAC address](./macaddress.md) object. ### WWN @@ -78,10 +86,17 @@ If selected, this component will be treated as if a cable has been connected. ### Parent Interface -Virtual interfaces can be bound to a physical parent interface. This is helpful for modeling virtual interfaces which employ encapsulation on a physical interface, such as an 802.1Q VLAN-tagged subinterface. +Virtual interfaces can be bound to a physical parent interface. This is helpful for modeling virtual interfaces which employ encapsulation on a physical interface, such as an 802.1Q VLAN-tagged subinterface. A channel subinterface is likewise bound to its [channelized](#channels) parent interface, whether it uses the generic **channel** type or its own specific physical type. !!! note - An interface with one or more child interfaces assigned cannot be deleted until all its child interfaces have been deleted or reassigned. + An interface with one or more child interfaces assigned cannot be deleted until all its child interfaces have been deleted or reassigned. Renaming a channelized interface updates the names of any channel subinterfaces which follow the `:` convention, to keep their names consistent with their new parent, unless the resulting name is already in use by another interface on the device or would exceed the maximum length of the name field (in either case, that subinterface's name is left unchanged). + +### Channel ID + +The numeric channel on a [channelized](#channels) parent interface to which this subinterface is bound, identifying it as a channel subinterface. This may be set on the generic **channel** type, or on any other physical interface type (e.g. to record the specific transceiver used on that channel) — but not on a virtual or wireless interface. The channel ID must fall within the range of channels provided by the parent (e.g. one through four for a parent with four channels). A channel subinterface derives its cable connection from the parent's; it cannot be cabled directly. + +!!! note "Channel IDs are one-indexed" + Channel IDs increment starting at one, even for interfaces with a zero-based identifier. This ensures that each subinterface maps cleanly to the profile of an attached cable. ### Bridged Interface diff --git a/docs/models/dcim/module.md b/docs/models/dcim/module.md index 060c2b094..cbc16cbd7 100644 --- a/docs/models/dcim/module.md +++ b/docs/models/dcim/module.md @@ -4,6 +4,20 @@ A module is a field-replaceable hardware component installed within a device whi Similar to devices, modules are instantiated from [module types](./moduletype.md), and any components associated with the module type are automatically instantiated on the new model. Each module must be installed within a [module bay](./modulebay.md) on a [device](./device.md), and each module bay may have only one module installed in it. +## Moving Modules + +!!! info "This feature was introduced in NetBox v4.7." + +An installed module can be moved to a different module bay after creation. The destination bay must be enabled and unoccupied. Moving a module relocates its entire subtree: the components installed by the module, the module bays belonging to it, and any child modules installed within those bays. + +Component names, labels, and module bay positions derived from the module type's templates (for example, names containing `{module}`) are re-resolved for the destination bay. A component is renamed only when its current name matches exactly one of the module type's templates as resolved for the source bay; components whose names do not match any template resolution (including manually renamed components) are preserved as-is. All resulting names are validated against the destination device before the move is applied. A move is rejected when a template-derived name, label, or position would exceed the destination field's maximum length. A move is also rejected when a component's current value matched a template for the source bay but that template cannot be resolved for the destination bay's nesting depth. + +Moving a module to a different device is supported only when the moved components carry no active topology or device-scoped configuration. A cross-device move is rejected while any moved component is cabled or marked as connected, has attached inventory items, or any moved interface has IP addresses, FHRP group assignments, tunnel terminations, L2VPN terminations, virtual circuit terminations, wireless links, wireless LAN assignments, VLANs (untagged, tagged, or Q-in-Q service), a VLAN translation policy, VDC assignments, or a VRF. A parent, bridge, LAG, power outlet to power port, cooling outflow to cooling intake, or front/rear port mapping relation crossing the moved module's boundary in either direction also blocks the move. MAC addresses move together with their interfaces. + +A cooling intake's upstream [cooling outflow](./coolingoutflow.md) is not device-scoped — an intake is routinely supplied by an outflow on another device, such as a CDU — so that assignment is preserved across a cross-device move rather than blocking it. + +Via the REST API, a module can be moved by patching only `module_bay`; the device is derived from the target bay. Changing a module's type and moving it must be performed as separate operations. + ## Fields ### Device @@ -40,3 +54,7 @@ Controls whether templates module type components are automatically added when c ### Adopt Components Controls whether pre-existing components assigned to the device with the same names as components that would be created automatically will be assigned to the new module. + +## Bay Type Compatibility + +If the module bay has [bay types](./modulebaytype.md) assigned and the module's type also has bay types assigned, NetBox verifies that the two sets share at least one type in common. An installation that fails this check will be rejected. The `is_bay_compatible` flag is exposed in the REST API to indicate compatibility status without performing a write. diff --git a/docs/models/dcim/modulebay.md b/docs/models/dcim/modulebay.md index d42828b83..4df243ca1 100644 --- a/docs/models/dcim/modulebay.md +++ b/docs/models/dcim/modulebay.md @@ -30,6 +30,10 @@ An alternative physical label identifying the module bay. The numeric position in which this module bay is situated. For example, this would be the number assigned to a slot within a chassis-based switch. +### Bay Types + +Zero or more [module bay types](./modulebaytype.md) assigned to this bay. When at least one bay type is set, only module types that share a common bay type may be installed. Leave empty to allow any module type. + ### Enabled Whether this module bay is enabled. Disabled module bays are not available for installation. diff --git a/docs/models/dcim/modulebaytemplate.md b/docs/models/dcim/modulebaytemplate.md index 3d5845d2e..1c8d6cd38 100644 --- a/docs/models/dcim/modulebaytemplate.md +++ b/docs/models/dcim/modulebaytemplate.md @@ -1,3 +1,7 @@ # Module Bay Templates A template for a module bay that will be created on all instantiations of the parent device type. See the [module bay](./modulebay.md) documentation for more detail. + +[Bay types](./modulebaytype.md) assigned to a module bay template are copied to each instantiated module bay, so constraints defined on the device type propagate automatically to all devices of that type. + +Bay types are importable as part of a device type's or module type's YAML definition (`module-bays[].module_bay_types`), referenced by name. They are included when a device type is exported; a module type's exported definition omits its module bays entirely, so bay types are not carried through it. A referenced name is resolved against bay types belonging to the parent type's own manufacturer or with no manufacturer set (global); a name may match both, since a bay type's uniqueness is scoped to `(manufacturer, name)` rather than name alone, in which case the manufacturer-specific type takes precedence. A name matching only some other manufacturer's bay type is rejected rather than resolved to it. diff --git a/docs/models/dcim/modulebaytype.md b/docs/models/dcim/modulebaytype.md new file mode 100644 index 000000000..c86ef1964 --- /dev/null +++ b/docs/models/dcim/modulebaytype.md @@ -0,0 +1,37 @@ +# Module Bay Types + +!!! info "This feature was introduced in NetBox v4.7." + +Module bay types are user-defined labels that can be assigned to [module bays](./modulebay.md) and [module types](./moduletype.md) to restrict which modules may be installed into which bays. This is useful for modeling chassis hardware where not every bay accepts every type of line card. + +When **both** a module bay and the module type being installed have at least one bay type assigned, NetBox will check for a non-empty intersection. If the two sets share no bay types in common, the installation will be rejected as incompatible. + +If either the bay or the module type has **no bay types assigned**, the constraint is not applied and any module type may be installed — this preserves backwards compatibility with existing data. + +!!! tip + Bay types function as an allow-list: assign the same type to a bay and to the module types that fit it, and leave the type unset on bays or module types where no restriction is needed. + +!!! note "GraphQL naming" + In the GraphQL API, the type for the `ModuleBay` *component* is named `ModuleBayType` (following the project's `Type` suffix convention), while the type for the `ModuleBayType` *model* is named `ModuleBayTypeType`. This is an unavoidable consequence of the naming convention colliding with this model's name. + +## Fields + +### Name + +A unique human-readable name for the bay type (e.g. `LC Line Card`, `Power Supply`, `Fan Tray`). + +### Slug + +A URL-friendly identifier derived from the name. + +### Manufacturer + +An optional [manufacturer](./manufacturer.md) associated with this bay type. Useful when a vendor uses proprietary slot designations. + +### Description + +A brief description of the bay type. + +### Comments + +Free-form Markdown-supported notes. \ No newline at end of file diff --git a/docs/models/dcim/moduletype.md b/docs/models/dcim/moduletype.md index e2e42620d..9f491c8bb 100644 --- a/docs/models/dcim/moduletype.md +++ b/docs/models/dcim/moduletype.md @@ -75,10 +75,24 @@ The numeric weight of the module, including a unit designation (e.g. 3 kilograms The direction in which air circulates through the device chassis for cooling. +### Cooling Method + +The cooling method employed by modules of this type (air, liquid, hybrid, or immersion). This is useful for liquid-cooled modules such as direct-to-chip accelerator (OAM) modules or liquid-cooled line cards. + +### End of Life + +The date after which this module type is no longer supported by its manufacturer. This can be used to identify modules approaching or past their support horizon to aid in hardware lifecycle planning. + ### Profile The assigned [profile](./moduletypeprofile.md) for the type of module. Profiles can be used to classify module types by function (e.g. power supply, hard disk, etc.), and they support the addition of user-configurable attributes on module types. The assignment of a module type to a profile is optional. +### Bay Types + +Zero or more [module bay types](./modulebaytype.md) that this module type is compatible with. When at least one bay type is set, the module type may only be installed into bays that share a common type. Leave empty to allow installation into any bay. + +Bay types are included, by name, in a module type's exported YAML definition, but are not currently importable back through it; re-importing an exported definition leaves this field unset. + ### Attributes Depending on the module type's assigned [profile](./moduletypeprofile.md) (if any), one or more user-defined attributes may be available to configure. diff --git a/docs/models/dcim/platform.md b/docs/models/dcim/platform.md index 3400294e6..60a57894c 100644 --- a/docs/models/dcim/platform.md +++ b/docs/models/dcim/platform.md @@ -12,8 +12,6 @@ The assignment of platforms to devices and virtual machines is optional. ## Parent -!!! "This field was introduced in NetBox v4.4." - The parent platform class to which this platform belongs (optional). ### Name diff --git a/docs/models/dcim/rack.md b/docs/models/dcim/rack.md index 52df4aaf8..55e42ba5b 100644 --- a/docs/models/dcim/rack.md +++ b/docs/models/dcim/rack.md @@ -28,6 +28,9 @@ The rack's name or identifier. Must be unique to the rack's location, if assigne The [physical type](./racktype.md) of this rack. The rack type defines physical attributes such as height and weight. +!!! warning "Rack type assignment will become mandatory" + Beginning in NetBox v5.0, the assignment of a rack type will be required, and several physical attributes will be inferred from it rather than being set directly on the rack. See the note under [Physical Attributes](#physical-attributes) below. + ### Status Operational status. @@ -51,5 +54,32 @@ The unique physical serial number assigned to this rack. A unique, locally-administered label used to identify hardware resources. -!!! note - Some additional fields pertaining to physical attributes such as height and weight can also be defined on each rack, but should generally be defined instead on the [rack type](./racktype.md). +### Cooling Capability + +Describes how the rack is able to cool the equipment installed in it, which indicates what kind of equipment it can accommodate: + +- **Air-only**: The rack is cooled by airflow only; no coolant is delivered to it. Only air-cooled equipment can be installed. +- **Hybrid**: Coolant can be delivered to the rack (e.g. via a [cooling feed](./coolingfeed.md)), but it can also house air-cooled equipment. Suitable for mixed or hybrid deployments. +- **Liquid-only**: The rack is intended exclusively for liquid-cooled equipment (such as direct-to-chip or immersion systems) and does not provide adequate air cooling on its own. + +This attribute documents the rack's intended use so that incompatible equipment—such as high-density liquid-cooled hardware in an air-only rack—can be identified. When the rack is assigned a [rack type](./racktype.md), this value is inherited from the rack type. + +### Cooling Capacity + +The rack's cooling capacity, expressed in kilowatts (kW). When the rack is assigned a [rack type](./racktype.md), this value is inherited from the rack type. + +## Physical Attributes + +Several physical attributes may be defined on each rack, including its width, height, outer dimensions, mounting depth, and weight. These should generally be defined on the [rack type](./racktype.md) assigned to the rack rather than on the rack itself. + +!!! warning "Some rack fields are deprecated" + The following fields have been **deprecated** on the rack model and are planned for removal in NetBox v5.0: + + * Form factor + * Width + * Outer width + * Outer height + * Outer depth + * Outer unit + + In a future release, the values for these attributes will be inferred from the rack's assigned [rack type](./racktype.md), which will become a mandatory assignment. Users are strongly encouraged to define these attributes on a rack type and assign it to each rack. (Note that the U height, starting unit, descending units, and mounting depth fields will be retained on the rack model, as these may legitimately vary among individual racks of the same type.) diff --git a/docs/models/dcim/racktype.md b/docs/models/dcim/racktype.md index ecaf539c9..da711d0cd 100644 --- a/docs/models/dcim/racktype.md +++ b/docs/models/dcim/racktype.md @@ -54,6 +54,14 @@ The numeric weight of the rack, including a unit designation (e.g. 10 kilograms The maximum total weight capacity for all installed devices, inclusive of the rack itself. +### Cooling Capability + +The rack design's coolant capability: air-only, hybrid, or liquid-only. Racks of this type inherit this value. + +### Cooling Capacity + +The rack design's cooling capacity, expressed in kilowatts (kW). Racks of this type inherit this value. + ### Descending Units If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use ascending numbering, with unit 1 assigned to the bottommost position.) diff --git a/docs/models/extras/customfield.md b/docs/models/extras/customfield.md index 7aeae7dad..42f8833e9 100644 --- a/docs/models/extras/customfield.md +++ b/docs/models/extras/customfield.md @@ -12,6 +12,10 @@ Select the NetBox object type or types to which this custom field applies. The raw field name. This will be used in the database and API, and should consist only of alphanumeric characters and underscores. (Use the `label` field to designate a human-friendly name for the custom field.) +### Status + +The field's lifecycle state: `active`, `provisioning`, or `deleting`. This is maintained by NetBox and cannot be set directly. A field is available for use only while active; see [field status](../../customization/custom-fields.md#field-status). + ### Label An optional human-friendly name for the custom field. If not defined, the field's `name` attribute will be used. @@ -109,6 +113,10 @@ Choice sets may optionally define colors for individual values. Colored choices If enabled, values from this field will be automatically pre-populated when cloning existing objects. +### Nulls First + +When ordering objects by this custom field, controls whether objects with no value (null) are sorted before or after objects that have a value. This option is enabled by default. + ### Minimum Value For numeric custom fields only. The minimum valid value (optional). diff --git a/docs/models/extras/eventrule.md b/docs/models/extras/eventrule.md index 9dca04529..b3232105c 100644 --- a/docs/models/extras/eventrule.md +++ b/docs/models/extras/eventrule.md @@ -47,6 +47,11 @@ The type of action to take when the rule triggers. This must be one of the follo * Custom script * Notification +!!! tip "Custom Action Types" + The above list includes only built-in action types. NetBox plugins can also [register their own custom action types](../../plugins/development/event-rule-actions.md). + + If the plugin providing an event rule's action type is uninstalled or disabled, the event rule is not deleted, but it is marked as unavailable and will not run. It also cannot be saved -- even to edit an unrelated field -- until either the plugin is reinstalled or the action type is changed to a currently-available one. + ### Action Data An optional dictionary of JSON data to pass when executing the rule. This can be useful to include additional context data, e.g. when transmitting a webhook. diff --git a/docs/models/extras/webhook.md b/docs/models/extras/webhook.md index 07db9e33c..2a0d2845b 100644 --- a/docs/models/extras/webhook.md +++ b/docs/models/extras/webhook.md @@ -87,6 +87,17 @@ Controls whether validation of the receiver's SSL certificate is enforced when H The file path to a particular certificate authority (CA) file to use when validating the receiver's SSL certificate (if not using the system defaults). +### Timeout + +The maximum time (in seconds) to wait for a response from the receiver before the request is considered failed. If left blank, the global [`WEBHOOK_DEFAULT_TIMEOUT`](../../configuration/miscellaneous.md#webhook_default_timeout) configuration value is used. + +The timeout must be less than [`RQ_DEFAULT_TIMEOUT`](../../configuration/miscellaneous.md#rq_default_timeout) (300 seconds by default), and NetBox will refuse to save a webhook which violates this. The background job timeout is a hard ceiling on how long a webhook request can run, so a value at or above it leaves no room for the request's own timeout to apply. + +!!! note + Staying below the job timeout makes it *likely*, but does not guarantee, that the request times out on its own. The timeout is applied separately to establishing the connection and to waiting for data, rather than to the request as a whole, so a receiver which stalls at both stages — or which responds slowly but continuously — can still outlast the job timeout and be terminated by the worker instead. + +When a request does time out, the failure is recorded by the `netbox.webhooks` logger and the background job is marked as failed. + ## Context Data The following context variables are available to the text and link templates. @@ -96,10 +107,9 @@ The following context variables are available to the text and link templates. | `event` | The event type (`create`, `update`, or `delete`) | | `timestamp` | The time at which the event occurred | | `object_type` | The type of object impacted (`app_label.model_name`) | -| `username` | The name of the user associated with the change | -| `request_id` | The unique request ID | | `data` | A complete serialized representation of the object | | `snapshots` | Pre- and post-change snapshots of the object | +| `request` | Data about the triggering request (if available) | -!!! warning "Deprecation of legacy fields" - The `request_id` and `username` fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0. Use `request.user` and `request.id` from the `request` object included in the callback context instead. (Note that `request` is populated in the context only when the webhook is associated with a triggering request.) +!!! note + The `request` variable is populated in the context only when the webhook is associated with a triggering request. It exposes `request.id` (the unique request ID) and `request.user` (the name of the user associated with the change), among other attributes. diff --git a/docs/models/ipam/iprange.md b/docs/models/ipam/iprange.md index 760dde699..e242c07ed 100644 --- a/docs/models/ipam/iprange.md +++ b/docs/models/ipam/iprange.md @@ -44,8 +44,6 @@ The IP range's operational status. Note that the status of a range does _not_ ha ### Mark Populated -!!! note "This field was added in NetBox v4.3." - If enabled, NetBox will treat this IP range as being fully populated when calculating available IP space. It will also prevent the creation of IP addresses which fall within the declared range (and assigned VRF, if any). ### Mark Utilized diff --git a/docs/models/ipam/service.md b/docs/models/ipam/service.md index fc6ab73d2..3bd4a2237 100644 --- a/docs/models/ipam/service.md +++ b/docs/models/ipam/service.md @@ -4,9 +4,8 @@ An application service represents a layer seven application available on a devic To aid in the efficient creation of application services, users may opt to first create an [application service template](./servicetemplate.md) from which service definitions can be quickly replicated. -!!! note "Changed in NetBox v4.4" - - Previously, application services were referred to simply as "services". The name has been changed in the UI to better reflect their intended use. There is no change to the name of the model or in any programmatic NetBox APIs. +!!! note "Naming" + Application services are referred to simply as "services" in the name of the model and throughout NetBox's programmatic APIs. Only the UI uses the longer name, which better reflects their intended use. ## Fields @@ -15,22 +14,66 @@ To aid in the efficient creation of application services, users may opt to first The parent object to which the application service is assigned. This must be one of [Device](../dcim/device.md), [VirtualMachine](../virtualization/virtualmachine.md), or [FHRP Group](./fhrpgroup.md). -!!! note "Changed in NetBox v4.3" - - Previously, `parent` was a property that pointed to either a Device or Virtual Machine. With the capability to assign services to FHRP groups, this is a unified in a concrete field. - ### Name A service or protocol name. -### Protocol +### Port Mappings -The wire protocol on which the service runs. Choices include UDP, TCP, and SCTP. +The protocols and ports on which the service runs. A service may expose the same port on multiple protocols — for example, DNS listening on both `tcp/53` and `udp/53`. In the UI, ports for a given protocol may be entered together using commas and/or hyphens (e.g. `80,8001-8003`). -### Ports +In the REST and GraphQL APIs, port mappings are represented as a flat list of `protocol/port` strings — matching how they are stored: -One or more numeric ports to which the service is bound. Multiple ports can be expressed using commas and/or hyphens. For example, `80,8001-8003` specifies ports 80, 8001, 8002, and 8003. +```json +[ + "tcp/80", + "tcp/443", + "udp/53" +] +``` + +!!! note "Changed in NetBox v4.7" + + The single-protocol `protocol` and `ports` fields have been replaced by the unified `port_mappings` field, which supports multiple protocols per service. For backward compatibility, the REST and GraphQL APIs still expose the legacy `protocol` and `ports` fields, and the REST API still accepts them on write as an alternative to `port_mappings`. They are populated for single-protocol services; a service with multiple protocols cannot be represented in the legacy format and returns `null` for both, while a service with no mappings returns `protocol: null` and `ports: []`. In other words, `ports: null` specifically signals "multiple protocols — read `port_mappings` instead." **These legacy fields are deprecated and will be removed in NetBox v5.0; use `port_mappings` instead.** + + On write, `port_mappings` and the legacy `protocol`/`ports` fields may be submitted together only when they agree — as in a full-object round-trip that echoes back a read. A request whose legacy fields contradict `port_mappings` (for example, an edited `port_mappings` sent alongside the stale `protocol`/`ports` from the original read) is rejected as ambiguous; send `port_mappings` alone, or keep the legacy fields consistent with it. + + At the ORM level (custom scripts and plugins), `protocol` and `ports` are now **read-only** properties derived from `port_mappings`. Assign `port_mappings` directly — e.g. `Service(parent=device, name='http', port_mappings=['tcp/80'])` — since passing `protocol=`/`ports=` to the model raises `TypeError` and setting `service.ports = [...]` raises `AttributeError`. + +### Filtering by Port Mapping, Protocol, and Port + +`port_mappings`, `protocol`, and `port` are all filtered against the `port_mappings` array. Each accepts multiple values (matching any of them), and `port` supports the usual numeric lookups: + +| Parameter | Matches services having a mapping… | +|---|---| +| `?port_mappings=tcp/80` | that is exactly `tcp/80` | +| `?port_mappings__n=tcp/80` | *(negated)* that is exactly `tcp/80` | +| `?protocol=tcp` | whose protocol is TCP | +| `?protocol__n=tcp` | *(negated)* whose protocol is TCP | +| `?port=80` | whose port is 80 | +| `?port__n=80` | *(negated)* whose port is 80 | +| `?port__gt=` / `?port__gte=` / `?port__lt=` / `?port__lte=` | whose port is above/below the given value | + +`port_mappings` is the most direct way to ask "which services expose this exact protocol and port?" — `?port_mappings=tcp/80` will not match a service that exposes only `udp/80`. Protocols may be given in any case, and leading zeros are ignored, so `?port_mappings=TCP/080` finds `tcp/80`. A value naming an unknown protocol or a malformed pair simply matches nothing rather than returning an error. + +When `protocol` and one or more `port` lookups are combined, they must all be satisfied by a **single** mapping. So `?protocol=tcp&port__gt=1000` does not match a service whose only TCP mapping is `tcp/80` (even if it also exposes `udp/9999`), and `?port__gte=1000&port__lte=2000` does not match a service exposing only ports 500 and 5000. Each `port_mappings` value already names one complete pair, so it needs no such correlation and is simply combined with the other parameters. + +All of these parameters are available as GraphQL filters too, under the same names — `port_mappings`, `protocol`, `port`, `port__gt`, `port__gte`, `port__lt`, `port__lte` — each accepting a list of values. For example, `filters: {port_mappings: ["tcp/80"]}` or `filters: {protocol: [TCP], port__gt: [1000]}`. The single-mapping correlation rule described above applies identically. + +!!! warning "GraphQL filter change in NetBox v4.7" + + The GraphQL filters for `Service` and `ServiceTemplate` have changed shape. The former `protocol` lookup and `ports` integer lookup (which nested their comparisons, e.g. `ports: {gt: 1000}`) are replaced by the flat `protocol`, `port`, `port__gt`, `port__gte`, `port__lt`, `port__lte`, and `port_mappings` parameters, each accepting a list of values and spelled the same way as the corresponding REST query parameter. Rewrite `ports: {gt: 1000}` as `port__gt: [1000]`, and `ports: {exact: 80}` as `port: [80]`. The `range` and `i_exact` lookups previously offered by the integer lookup have no direct equivalent; express a range as `port__gte`/`port__lte`, which — unlike the old lookup — requires a single mapping to satisfy both bounds. + + The members of the `ServiceProtocolEnum` used by the `protocol` filter have also been renamed to drop a spurious `ROLE_` prefix: `ROLE_TCP`, `ROLE_UDP`, and `ROLE_SCTP` are now `TCP`, `UDP`, and `SCTP`. + +!!! warning "REST filter change in NetBox v4.7" + + Because `protocol` is now filtered against the `port_mappings` array rather than a dedicated model field, the character-based lookup variants previously auto-generated for it — `protocol__ic`, `protocol__nic`, `protocol__isw`, `protocol__empty`, etc. — are no longer available; `protocol` and `protocol__n` remain. The `port__empty` lookup is likewise gone, as a service always has at least one port mapping. As with any unrecognized query parameter, the REST API silently ignores a removed lookup rather than raising an error, so update any saved filters or scripts that relied on them. ### IP Addresses The [IP address(es)](./ipaddress.md) to which this service is bound. If no IP addresses are bound, the service is assumed to be reachable via any assigned IP address. + +## Bulk Import (CSV) + +When importing application services or [application service templates](./servicetemplate.md) via CSV, all port mappings for a row are given in a single `port_mappings` column as a comma-separated list of `protocol/port` pairs enclosed in double quotes. For example, `"tcp/80,tcp/443,udp/53"`. A pair's port may be given as a hyphen range, for example `"tcp/8000-8010"`. Protocols may be entered in uppercase or lowercase. diff --git a/docs/models/ipam/servicetemplate.md b/docs/models/ipam/servicetemplate.md index 9dd69b3c4..0c9090377 100644 --- a/docs/models/ipam/servicetemplate.md +++ b/docs/models/ipam/servicetemplate.md @@ -2,9 +2,9 @@ Application service templates can be used to instantiate [application services](./service.md) on [devices](../dcim/device.md) and [virtual machines](../virtualization/virtualmachine.md). -!!! note "Changed in NetBox v4.4" +!!! note "Naming" - Previously, application service templates were referred to simply as "service templates". The name has been changed in the UI to better reflect their intended use. There is no change to the name of the model or in any programmatic NetBox APIs. + Application service templates are referred to simply as "service templates" in the name of the model and throughout NetBox's programmatic APIs. Only the UI uses the longer name, which better reflects their intended use. ## Fields @@ -12,10 +12,10 @@ Application service templates can be used to instantiate [application services]( A service or protocol name. -### Protocol +### Port Mappings -The wire protocol on which the service runs. Choices include UDP, TCP, and SCTP. +The protocols and ports on which the service runs. See [Port Mappings](./service.md#port-mappings) on the application service model for details. -### Ports +## Bulk Import (CSV) -One or more numeric ports to which the service is bound. Multiple ports can be expressed using commas and/or hyphens. For example, `80,8001-8003` specifies ports 80, 8001, 8002, and 8003. +Application service templates are imported via CSV using the same `port_mappings` column format as application services. See [Bulk Import (CSV)](./service.md#bulk-import-csv) on the application service model for details. diff --git a/docs/models/virtualization/virtualmachine.md b/docs/models/virtualization/virtualmachine.md index f8786796c..001f37659 100644 --- a/docs/models/virtualization/virtualmachine.md +++ b/docs/models/virtualization/virtualmachine.md @@ -46,9 +46,6 @@ The location or host for this VM. At least one must be specified: - **Device only**: The VM runs directly on a physical host device without a cluster (e.g. containers). The site is automatically inferred from the device's site. - **Cluster + Device**: The VM belongs to a cluster and is pinned to a specific host device within that cluster. The device must be a registered host of the assigned cluster. -!!! info "New in NetBox v4.6" - Virtual machines can now be assigned directly to a device without requiring a cluster. This is particularly useful for modeling VMs running on standalone hosts outside of a cluster. - ### Platform A VM may be associated with a particular [platform](../dcim/platform.md) to indicate its operating system. If a virtual machine type defines a default platform, it will be applied when the VM is created unless an explicit platform is specified. diff --git a/docs/models/virtualization/vminterface.md b/docs/models/virtualization/vminterface.md index 726060c05..d4b54d332 100644 --- a/docs/models/virtualization/vminterface.md +++ b/docs/models/virtualization/vminterface.md @@ -31,7 +31,7 @@ If not selected, this interface will be treated as disabled/inoperative. The [MAC address](../dcim/macaddress.md) assigned to this interface which is designated as its primary. -!!! note "Changed in NetBox v4.2" +!!! note "MAC address is a property" The MAC address of an interface (formerly a concrete database field) is available as a property, `mac_address`, which reflects the value of the primary linked [MAC address](../dcim/macaddress.md) object. ### MTU diff --git a/docs/plugins/development/background-jobs.md b/docs/plugins/development/background-jobs.md index d225c414a..192fdae8f 100644 --- a/docs/plugins/development/background-jobs.md +++ b/docs/plugins/development/background-jobs.md @@ -41,8 +41,6 @@ This is the human-friendly names of your background job. If omitted, the class n ### Logging -!!! info "This feature was introduced in NetBox v4.4." - A Python logger is instantiated by the runner for each job. It can be utilized within a job's `run()` method as needed: ```python diff --git a/docs/plugins/development/config-templates.md b/docs/plugins/development/config-templates.md new file mode 100644 index 000000000..8f8bb2477 --- /dev/null +++ b/docs/plugins/development/config-templates.md @@ -0,0 +1,117 @@ +# Jinja Config Templates + +!!! info "This feature was introduced in NetBox v4.7." + +NetBox uses [Jinja](https://jinja.palletsprojects.com/) to render [configuration templates](../../features/configuration-rendering.md). Plugins can extend this rendering pipeline in two complementary ways: + +1. **Register custom filters** — make new template filters available by name in every config template. +2. **Inject context variables** — add extra variables that are available inside every config template render. + +--- + +## Registering Jinja Filters + +### Via `jinja_env.py` (auto-discovery) + +Create a file named `jinja_env.py` in your plugin root and expose a dict called `filters`. NetBox will auto-discover and register it when the plugin loads. + +```python title="my_plugin/jinja_env.py" +def prefix_list(device): + """Return all prefixes assigned to a device's interfaces.""" + return [ + str(ip.address) + for iface in device.interfaces.all() + for ip in iface.ip_addresses.all() + ] + +filters = { + 'prefix_list': prefix_list, +} +``` + +The filter is then available in any config template: + +```jinja2 +{% for prefix in device | prefix_list %} + network {{ prefix }} +{% endfor %} +``` + +### Via `register_jinja_filters()` + +You can also register filters programmatically inside your plugin's `ready()` method: + +```python title="my_plugin/__init__.py" +from netbox.plugins import PluginConfig + +class MyPluginConfig(PluginConfig): + name = 'my_plugin' + # ... + + def ready(self): + super().ready() + from netbox.plugins.registration import register_jinja_filters + from .jinja_env import filters + register_jinja_filters(filters) +``` + +`register_jinja_filters()` accepts a `dict` mapping filter names to callables. It raises `TypeError` if passed a non-dict or if any value is not callable. + +### Precedence + +The full filter precedence from lowest to highest is: **NetBox built-in filters** (e.g. `env`) → **plugin-registered filters** → **instance [`JINJA_FILTERS`](../../configuration/system.md#jinja_filters)**. Instance-level filters always win, so site admins can override anything without touching a plugin. + +If two plugins register a filter with the same name, the later-loaded plugin's version wins and NetBox will log a warning. + +For example, if `my_plugin` registers a `prefix_list` filter but a site needs different behaviour, the operator can replace it in `configuration.py` without touching the plugin: + +```python title="configuration.py" +def prefix_list(device): + # Site-local override: include only loopback prefixes + return [ + str(ip.address) + for iface in device.interfaces.filter(type='loopback') + for ip in iface.ip_addresses.all() + ] + +JINJA_FILTERS = { + 'prefix_list': prefix_list, +} +``` + +--- + +## Injecting Context Variables + +Override `get_jinja_context()` in your `PluginConfig` subclass to inject additional variables into every config template render context. + +```python title="my_plugin/__init__.py" +from netbox.plugins import PluginConfig + +class MyPluginConfig(PluginConfig): + name = 'my_plugin' + # ... + + def get_jinja_context(self): + from .utils import MyNamespace + return { + 'my_plugin': MyNamespace(), + } +``` + +The returned dict is merged into the template context, so `my_plugin` becomes available by name inside every config template: + +```jinja2 +{% set records = my_plugin.lookup(device.name) %} +``` + +!!! warning "Startup cost" + `get_jinja_context()` is called on **every** config template render, not once at startup. Keep it fast. Defer expensive lookups to the object you return rather than performing them in `get_jinja_context()` itself. + +!!! note "Conflict avoidance" + Choose context variable names that are unlikely to collide with NetBox's built-in template variables (`device`, `queryset`, etc.) or with those contributed by other plugins. Prefixing with your plugin name is strongly recommended. + + In addition, avoid top-level app-label names (`dcim`, `ipam`, `virtualization`, etc.). The auto-populated template context maps each app label to a dict of its public model classes; returning a key like `'dcim'` from `get_jinja_context()` will silently replace that entire namespace. + +!!! note "No per-render context" + `get_jinja_context()` receives no arguments — it has no access to the object being rendered or the caller-supplied context. It is intended for plugin-global namespaces (e.g. a lazily-evaluated query helper). Per-object logic belongs in the template itself or in a custom filter. diff --git a/docs/plugins/development/event-rule-actions.md b/docs/plugins/development/event-rule-actions.md new file mode 100644 index 000000000..2be9bccd6 --- /dev/null +++ b/docs/plugins/development/event-rule-actions.md @@ -0,0 +1,64 @@ +# Event Rule Actions + +!!! info "This feature was introduced in NetBox v4.7." + +[Event rules](../../models/extras/eventrule.md) dispatch to an *action* when a matching event occurs, such as sending a webhook request or running a script. Plugins can register their own action types to extend the list of actions an event rule can perform, by subclassing NetBox's `EventRuleAction` class. + +```python title="event_rules.py" +from django.utils.translation import gettext_lazy as _ +from netbox.event_rules import EventRuleAction + +from .models import Ticket + +class OpenTicketAction(EventRuleAction): + slug = 'my_plugin.open_ticket' + label = _('Open ticket') + description = _('Open a ticket in the external ticketing system') + object_model = Ticket + object_required = True + + def enqueue(self, *, event_rule, event_context, action_object, action_data): + ... +``` + +To register one or more event rule actions with NetBox, define a list named `event_rule_actions` at the end of this file: + +```python title="event_rules.py" +event_rule_actions = [OpenTicketAction] +``` + +!!! tip + The path to the list of event rule actions can be modified by setting `event_rule_actions` in the PluginConfig instance. + +A dotted namespace prefix (e.g. `my_plugin.open_ticket`) is strongly recommended for `slug` to avoid collisions with other plugins or with action types added to NetBox core in the future. + +`slug` must begin with a lowercase letter, and may contain only letters, digits, underscores, and dot-separated segments thereafter. **Hyphens are not allowed**, even though they're common in plugin/package names -- use an underscore instead, e.g. `my_plugin.open_ticket` as in the example above. `register_event_rule_action()` raises `ImproperlyConfigured` immediately for a slug outside this pattern, rather than allowing it to fail later during GraphQL schema assembly. + +`slug`/`label` are only required at registration time, not at class definition, so an intermediate base class shared by several concrete actions may leave them unset. + +!!! warning "Actions must be stateless" + Registration instantiates the class once, and that single instance serves every event rule, request, and background worker thread for the lifetime of the process. Do not stash per-event data on `self` in `enqueue()` or `validate()` -- concurrent dispatches would race over it. Everything an action needs is passed in as an argument. + +## Target Objects + +If an action operates against a specific object (e.g. a webhook targets a `Webhook` instance, and a script targets a `Script` instance), set `object_model` to the relevant model class. NetBox uses this to render the object-selection field on the event rule form and to validate the selected object's type. `object_required` defaults to `False` (matching `object_model`'s default of `None`); set it to `True` alongside `object_model` if the target object must always be selected. (Setting `object_required` *without* an `object_model` raises `ImproperlyConfigured` at registration, as it could never be satisfied.) Override `get_object_queryset()` to customize which objects are eligible for selection (e.g. to filter or further restrict the queryset). + +The object-selection field is labeled with `object_model`'s verbose name; set `object_label` to override it. + +If an action leaves `object_model` as `None`, event rules using it must not specify a target object: supplying one is rejected as a validation error rather than being silently stored. + +## Bulk Import + +To support resolving a target object from a CSV value during bulk import of event rules, override `resolve_import_object()`. Raise `django.core.exceptions.ObjectDoesNotExist` (or a subclass) if the supplied value doesn't resolve to an object. If this method is not overridden, event rules using this action type cannot be targeted at an object via bulk import. + +## Unregistered Actions + +An event rule's `action_type` is stored as a plain string, and is not validated against the set of currently-registered actions at the database level. This means an event rule can reference an action type provided by a plugin that is later uninstalled or disabled, without the row being deleted or corrupted. While its action type is unavailable: + +* The event rule is skipped during event processing (it does not raise an error, and does not prevent other event rules from being processed). +* It is displayed with an "unavailable" indicator in the UI. `action_is_available` is exposed as a read-only field via the REST API, and as a filter (`?action_is_available=false`), so affected event rules can be found in bulk. +* It cannot be saved via the UI or REST API -- even to edit an unrelated field -- until its `action_type` is changed to a currently-registered value. + +Reinstalling the plugin (and thereby re-registering the action type) automatically restores the event rule to working order, with no need to re-save it. + +::: netbox.event_rules.EventRuleAction diff --git a/docs/plugins/development/filtersets.md b/docs/plugins/development/filtersets.md index 36e6346c5..ca0947122 100644 --- a/docs/plugins/development/filtersets.md +++ b/docs/plugins/development/filtersets.md @@ -6,8 +6,7 @@ Filter sets define the mechanisms available for filtering or searching through a To support additional functionality standard to NetBox models, such as tag assignment and custom field support, the `NetBoxModelFilterSet` class is available for use by plugins. This should be used as the base filter set class for plugin models which inherit from `NetBoxModel`. Within this class, individual filters can be declared as directed by the `django-filters` documentation. An example is provided below. -!!! info "New in NetBox v4.5: FilterSet Registration" - NetBox v4.5 introduced the `register_filterset()` utility function. This enables plugins to register their filtersets to receive advanced functionality, such as the automatic attachment of field-specific lookup modifiers on the filter form. Registration is optional: Unregistered filtersets will continue to work as before, but will not receive the enhanced functionality. +The `register_filterset()` utility function enables plugins to register their filtersets to receive advanced functionality, such as the automatic attachment of field-specific lookup modifiers on the filter form. Registration is optional: Unregistered filtersets will continue to work as before, but will not receive the enhanced functionality. ```python # filtersets.py diff --git a/docs/plugins/development/forms.md b/docs/plugins/development/forms.md index afe05407e..227ffa506 100644 --- a/docs/plugins/development/forms.md +++ b/docs/plugins/development/forms.md @@ -210,6 +210,37 @@ In addition to the [form fields provided by Django](https://docs.djangoproject.c options: members: false +## Static Choice Fields + +!!! info "This feature was introduced in NetBox v4.7." + +These fields render a standard HTML `' + '', + url, get_token(request), _('Set as primary'), + ) + else: + # Inside the bulk-edit
: a nested is invalid HTML and gets dropped by the + # parser, so ride the surrounding form via formaction/formmethod instead (matching the + # DataSource sync button in core/tables/template_code.py). + action_li = format_html( + '
  • ', + url, _('Set as primary'), + ) + html_str = str(html) + if '' in html_str: + html = mark_safe(html_str.replace('', str(action_li) + '', 1)) + + return html + + class MACAddressTable(PrimaryModelTable): mac_address = tables.TemplateColumn( template_code=MACADDRESS_LINK, @@ -1241,7 +1316,8 @@ class MACAddressTable(PrimaryModelTable): tags = columns.TagColumn( url_name='dcim:macaddress_list' ) - actions = columns.ActionsColumn( + actions = MACAddressActionsColumn( + actions=('edit', 'delete', 'changelog', 'set_primary'), extra_buttons=MACADDRESS_COPY_BUTTON ) diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index f952c2d4a..3e4682d0d 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -128,6 +128,12 @@ class DeviceTypeTable(PrimaryModelTable): power_outlet_template_count = tables.Column( verbose_name=_('Power Outlets') ) + cooling_intake_template_count = tables.Column( + verbose_name=_('Cooling Intakes') + ) + cooling_outflow_template_count = tables.Column( + verbose_name=_('Cooling Outflows') + ) interface_template_count = tables.Column( verbose_name=_('Interfaces') ) @@ -146,12 +152,16 @@ class DeviceTypeTable(PrimaryModelTable): inventory_item_template_count = tables.Column( verbose_name=_('Inventory Items') ) + cooling_method = columns.ChoiceFieldColumn( + verbose_name=_('Cooling Method'), + ) class Meta(PrimaryModelTable.Meta): model = models.DeviceType fields = ( 'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height', - 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', + 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'cooling_method', 'weight', + 'end_of_life', 'description', 'comments', 'device_count', 'tags', 'created', 'last_updated', ) default_columns = ( @@ -244,8 +254,8 @@ class InterfaceTemplateTable(ComponentTemplateTable): class Meta(ComponentTemplateTable.Meta): model = models.InterfaceTemplate fields = ( - 'pk', 'name', 'label', 'enabled', 'mgmt_only', 'type', 'description', 'bridge', 'poe_mode', 'poe_type', - 'rf_role', 'actions', + 'pk', 'name', 'label', 'enabled', 'mgmt_only', 'type', 'channels', 'channel_id', 'description', 'parent', + 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'actions', ) empty_text = "None" @@ -292,13 +302,17 @@ class ModuleBayTemplateTable(ComponentTemplateTable): enabled = columns.BooleanColumn( verbose_name=_('Enabled'), ) + module_bay_types = columns.ManyToManyColumn( + verbose_name=_('Bay Types'), + linkify_item=True, + ) actions = columns.ActionsColumn( actions=('edit', 'delete') ) class Meta(ComponentTemplateTable.Meta): model = models.ModuleBayTemplate - fields = ('pk', 'name', 'label', 'position', 'enabled', 'description', 'actions') + fields = ('pk', 'name', 'label', 'position', 'enabled', 'module_bay_types', 'description', 'actions') empty_text = "None" diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py index 67959e92e..a8aebf873 100644 --- a/netbox/dcim/tables/modules.py +++ b/netbox/dcim/tables/modules.py @@ -1,18 +1,46 @@ import django_tables2 as tables from django.utils.translation import gettext_lazy as _ -from dcim.models import Module, ModuleType, ModuleTypeProfile +from dcim.models import Module, ModuleBayType, ModuleType, ModuleTypeProfile from netbox.tables import PrimaryModelTable, columns from .template_code import MODULETYPEPROFILE_ATTRIBUTES, WEIGHT __all__ = ( + 'ModuleBayTypeTable', 'ModuleTable', 'ModuleTypeProfileTable', 'ModuleTypeTable', ) +class ModuleBayTypeTable(PrimaryModelTable): + name = tables.Column( + verbose_name=_('Name'), + linkify=True + ) + manufacturer = tables.Column( + verbose_name=_('Manufacturer'), + linkify=True + ) + color = columns.ColorColumn( + verbose_name=_('Color'), + ) + tags = columns.TagColumn( + url_name='dcim:modulebaytype_list' + ) + + class Meta(PrimaryModelTable.Meta): + model = ModuleBayType + fields = ( + 'pk', 'id', 'name', 'slug', 'manufacturer', 'color', 'description', 'comments', 'tags', + 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'name', 'manufacturer', 'color', 'description', + ) + + class ModuleTypeProfileTable(PrimaryModelTable): name = tables.Column( verbose_name=_('Name'), @@ -47,10 +75,17 @@ class ModuleTypeTable(PrimaryModelTable): verbose_name=_('Manufacturer'), linkify=True ) + module_bay_types = columns.ManyToManyColumn( + verbose_name=_('Bay Types'), + linkify_item=True, + ) model = tables.Column( linkify=True, verbose_name=_('Module Type') ) + cooling_method = columns.ChoiceFieldColumn( + verbose_name=_('Cooling Method'), + ) weight = columns.TemplateColumn( verbose_name=_('Weight'), template_code=WEIGHT, @@ -71,8 +106,9 @@ class ModuleTypeTable(PrimaryModelTable): class Meta(PrimaryModelTable.Meta): model = ModuleType fields = ( - 'pk', 'id', 'model', 'profile', 'manufacturer', 'part_number', 'airflow', 'weight', 'description', - 'attributes', 'module_count', 'comments', 'tags', 'created', 'last_updated', + 'pk', 'id', 'model', 'profile', 'manufacturer', 'part_number', 'airflow', 'cooling_method', 'weight', + 'end_of_life', 'module_bay_types', + 'description', 'attributes', 'module_count', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'model', 'profile', 'manufacturer', 'part_number', 'module_count', diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 2ef1bd72c..028d4aad8 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -106,6 +106,12 @@ class RackTypeTable(PrimaryModelTable): url_params={'rack_type_id': 'pk'}, verbose_name=_('Rack Count'), ) + cooling_capability = columns.ChoiceFieldColumn( + verbose_name=_('Cooling Capability'), + ) + cooling_capacity = tables.Column( + verbose_name=_('Cooling Capacity (kW)') + ) tags = columns.TagColumn( url_name='dcim:rack_list' ) @@ -114,8 +120,8 @@ class RackTypeTable(PrimaryModelTable): model = RackType fields = ( 'pk', 'id', 'model', 'manufacturer', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width', - 'outer_height', 'outer_depth', 'mounting_depth', 'weight', 'max_weight', 'description', 'comments', - 'rack_count', 'tags', 'created', 'last_updated', + 'outer_height', 'outer_depth', 'mounting_depth', 'weight', 'max_weight', 'cooling_capability', + 'cooling_capacity', 'description', 'comments', 'rack_count', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'model', 'manufacturer', 'type', 'u_height', 'description', 'rack_count', @@ -196,13 +202,20 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable): template_code=WEIGHT, order_by=('_abs_max_weight', 'weight_unit') ) + cooling_capability = columns.ChoiceFieldColumn( + verbose_name=_('Cooling Capability'), + ) + cooling_capacity = tables.Column( + verbose_name=_('Cooling Capacity (kW)') + ) class Meta(PrimaryModelTable.Meta): model = Rack fields = ( 'pk', 'id', 'name', 'site', 'location', 'group', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'rack_type', 'serial', 'asset_tag', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width', - 'outer_height', 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'comments', + 'outer_height', 'outer_depth', 'mounting_depth', 'airflow', 'cooling_capability', + 'cooling_capacity', 'weight', 'max_weight', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'description', 'contacts', 'tags', 'created', 'last_updated', ) diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index c8baf2942..fe75164c7 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -52,6 +52,16 @@ WEIGHT = """ {% display_weight record.weight record.weight_unit record.abs_weight %} """ +DIAMETER = """ +{% load helpers %} +{% display_diameter record.diameter record.diameter_unit record.abs_diameter %} +""" + +MAX_FLOW = """ +{% load helpers %} +{% display_flow_rate record.max_flow record.max_flow_unit record.abs_max_flow %} +""" + DEVICE_LINK = """ {{ record.label|default:'Unnamed device' }} """ diff --git a/netbox/dcim/tests/query_counts.json b/netbox/dcim/tests/query_counts.json index 599b32873..0b24d82fb 100644 --- a/netbox/dcim/tests/query_counts.json +++ b/netbox/dcim/tests/query_counts.json @@ -11,6 +11,16 @@ "consoleserverport:api_list_objects": 13, "consoleserverport:list_objects_with_permission": 18, "consoleserverporttemplate:api_list_objects": 11, + "coolingfeed:api_list_objects": 14, + "coolingfeed:list_objects_with_permission": 19, + "coolingintake:api_list_objects": 13, + "coolingintake:list_objects_with_permission": 18, + "coolingintaketemplate:api_list_objects": 11, + "coolingoutflow:api_list_objects": 13, + "coolingoutflow:list_objects_with_permission": 19, + "coolingoutflowtemplate:api_list_objects": 11, + "coolingsource:api_list_objects": 14, + "coolingsource:list_objects_with_permission": 19, "device:api_list_objects": 19, "device:list_objects_with_permission": 22, "devicebay:api_list_objects": 13, @@ -38,12 +48,14 @@ "macaddress:list_objects_with_permission": 21, "manufacturer:api_list_objects": 12, "manufacturer:list_objects_with_permission": 17, - "module:api_list_objects": 17, + "module:api_list_objects": 19, "module:list_objects_with_permission": 21, - "modulebay:api_list_objects": 14, + "modulebay:api_list_objects": 16, "modulebay:list_objects_with_permission": 18, - "modulebaytemplate:api_list_objects": 11, - "moduletype:api_list_objects": 13, + "modulebaytemplate:api_list_objects": 13, + "modulebaytype:api_list_objects": 13, + "modulebaytype:list_objects_with_permission": 18, + "moduletype:api_list_objects": 15, "moduletype:list_objects_with_permission": 19, "moduletypeprofile:api_list_objects": 12, "moduletypeprofile:list_objects_with_permission": 17, diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 66a2e7c60..5ba406ee5 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1,14 +1,16 @@ import json from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.db import connection -from django.test import tag +from django.test import override_settings, tag from django.test.utils import CaptureQueriesContext from django.urls import reverse from django.utils.translation import gettext as _ from rest_framework import status -from core.models import ObjectType +from core.choices import ObjectChangeActionChoices +from core.models import ObjectChange, ObjectType from dcim.choices import * from dcim.constants import * from dcim.graphql.types import _CABLE_TERMINATION_MODELS @@ -28,6 +30,7 @@ from utilities.testing import ( create_test_device, create_test_nat_ip_pair, disable_logging, + disable_warnings, ) from virtualization.models import Cluster, ClusterType from wireless.choices import WirelessChannelChoices @@ -151,6 +154,9 @@ class SiteTestCase(APIViewTestCases.APIViewTestCase): bulk_update_data = { 'status': 'planned', } + bulk_update_invalid_data = { + 'status': 'not-a-valid-status', + } graphql_filter_tests = ( GraphQLFilterTest( name='tenant__name__exact', @@ -467,6 +473,499 @@ class SiteTestCase(APIViewTestCases.APIViewTestCase): response = self.client.patch(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + def test_bulk_update_objects_non_list_body(self): + """ + PATCH a list endpoint with a body which is not a list. The response should identify the + problem with the request as a whole, as there are no entries to report against. + """ + obj_perm = ObjectPermission(name='Test permission', actions=['change']) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) + + site = Site.objects.get(slug='site-1') + response = self.client.patch( + self._get_list_url(), {'id': site.pk, 'description': 'x'}, format='json', **self.header + ) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertIn('detail', response.data) + self.assertNotIn('errors', response.data) + + site.refresh_from_db() + self.assertEqual(site.description, '') + + # A non-list body is described by its type, so that the client can see what was sent + self.assertEqual(response.data['detail'], 'Expected a list of objects, but got dict.') + + # A multipart body reaches the bulk action as a QueryDict, which must be reported as the + # dict the client submitted rather than by that internal class name + response = self.client.patch( + self._get_list_url(), {'id': site.pk, 'description': 'x'}, format='multipart', **self.header + ) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data['detail'], 'Expected a list of objects, but got dict.') + + site.refresh_from_db() + self.assertEqual(site.description, '') + + def test_bulk_write_objects_empty_body(self): + """ + Address a list endpoint with no body at all. An absent body reaches the bulk actions as an + empty dict, so it must not be reported as having "got dict" -- there is no object to describe. + """ + obj_perm = ObjectPermission(name='Test permission', actions=['change', 'delete']) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) + + initial_count = Site.objects.count() + + for method in ('patch', 'put', 'delete'): + with self.subTest(method=method): + response = getattr(self.client, method)(self._get_list_url(), **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data['detail'], 'Expected a list of objects, but no data was submitted.' + ) + self.assertNotIn('errors', response.data) + + # An explicitly submitted empty object is indistinguishable, and reads the same way + response = self.client.patch(self._get_list_url(), {}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data['detail'], 'Expected a list of objects, but no data was submitted.' + ) + + self.assertEqual(Site.objects.count(), initial_count, 'No objects should have been deleted') + + def test_bulk_update_objects_non_numeric_id(self): + """ + PATCH a set of objects where one entry carries a non-numeric ID. The failure must be + correlated by position, in the same structured form as every other bulk error. + """ + obj_perm = ObjectPermission(name='Test permission', actions=['change']) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) + + site = Site.objects.get(slug='site-1') + data = [{'id': site.pk, 'description': 'x'}, {'id': 'not-a-number', 'description': 'y'}] + response = self.client.patch(self._get_list_url(), data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertEqual(len(response.data['errors']), 1) + self.assertEqual(response.data['errors'][0]['index'], 1) + self.assertIn('id', response.data['errors'][0]['errors']) + + # The valid entry must not have been applied + site.refresh_from_db() + self.assertEqual(site.description, '') + + def test_bulk_write_objects_null_entry(self): + """ + Address a list endpoint with a list containing a null entry. A null fails before any field + is considered, so its error arrives as a bare list of messages; it must still be reported + as a mapping keyed by field name, as the schema declares. + """ + obj_perm = ObjectPermission(name='Test permission', actions=['change', 'delete']) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) + + site = Site.objects.get(slug='site-1') + initial_count = Site.objects.count() + + for method in ('patch', 'put', 'delete'): + with self.subTest(method=method): + data = [{'id': site.pk, 'description': 'x'}, None] + response = getattr(self.client, method)( + self._get_list_url(), data, format='json', **self.header + ) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertEqual(len(response.data['errors']), 1) + self.assertEqual(response.data['errors'][0]['index'], 1) + + # Reported under the key which every other non-field error uses + entry_errors = response.data['errors'][0]['errors'] + self.assertIsInstance(entry_errors, dict) + self.assertIn('__all__', entry_errors) + + # The valid entry must not have been applied + site.refresh_from_db() + self.assertEqual(site.description, '') + self.assertEqual(Site.objects.count(), initial_count, 'No objects should have been deleted') + + def test_bulk_update_objects_duplicate_id_invalid_entry(self): + """ + PATCH a set of objects in which one object is named twice, once with invalid data and once + with valid data. The invalid entry must not be discarded in favor of the valid one. + """ + obj_perm = ObjectPermission(name='Test permission', actions=['change']) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) + + site = Site.objects.get(slug='site-1') + data = [ + {'id': site.pk, 'name': ''}, # Invalid: name is required + {'id': site.pk, 'name': 'Renamed Site'}, + ] + response = self.client.patch(self._get_list_url(), data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertEqual([e['id'] for e in response.data['errors']], [site.pk]) + + # The valid entry must not have been applied + site.refresh_from_db() + self.assertEqual(site.name, 'Site 1') + + def test_bulk_delete_objects_duplicate_id_changelog_message(self): + """ + DELETE a set of objects in which one object is named twice with differing changelog + messages. The request must be rejected rather than recording only one of the messages. + """ + obj_perm = ObjectPermission(name='Test permission', actions=['delete']) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) + + site = Site.objects.get(slug='site-1') + data = [ + {'id': site.pk, 'changelog_message': 'First message'}, + {'id': site.pk, 'changelog_message': 'Second message'}, + ] + response = self.client.delete(self._get_list_url(), data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertEqual([e['id'] for e in response.data['errors']], [site.pk]) + self.assertTrue(Site.objects.filter(pk=site.pk).exists()) + + def test_bulk_create_objects_conflicting(self): + """ + POST a set of objects in which two conflict with one another. Objects are created one at a + time, so the second must fail validation against the first rather than passing validation + and then raising an IntegrityError on save. + """ + obj_perm = ObjectPermission(name='Test permission', actions=['add']) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) + + initial_count = self._get_queryset().count() + data = [ + {'name': 'Site 10', 'slug': 'site-10'}, + {'name': 'Site 11', 'slug': 'site-11'}, + {'name': 'Site 10', 'slug': 'site-10'}, # Duplicates the first item + ] + response = self.client.post(self._get_list_url(), data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertEqual(self._get_queryset().count(), initial_count) + + # Only the third item failed; the first two were provisionally created and rolled back + self.assertEqual(len(response.data['errors']), 1) + self.assertEqual(response.data['errors'][0]['index'], 2) + self.assertIn('slug', response.data['errors'][0]['errors']) + + def test_bulk_delete_objects_protected(self): + """ + DELETE a set of objects where one has a protected FK dependency. Verify the structured + per-object error response and that no objects are deleted (atomic rollback). + """ + obj_perm = ObjectPermission(name='Test permission', actions=['delete']) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) + + # Site 1 has no dependent Device; Site 2 gets one (Device FK is on_delete=PROTECT) + site1 = Site.objects.get(slug='site-1') + site2 = Site.objects.get(slug='site-2') + create_test_device('Protected Device', site=site2) + + data = [{'id': site1.pk}, {'id': site2.pk}] + response = self.client.delete(self._get_list_url(), data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_409_CONFLICT) + self.assertIn('detail', response.data) + self.assertIn('errors', response.data) + self.assertEqual(len(response.data['errors']), 1) + + # Site 2 (has Device) should be the only entry, since Site 1 succeeded + self.assertEqual(response.data['errors'][0]['id'], site2.pk) + self.assertIn('errors', response.data['errors'][0]) + + # Verify that no sites were actually deleted (transaction rolled back) + self.assertTrue(Site.objects.filter(pk=site1.pk).exists(), 'Site 1 should not have been deleted') + self.assertTrue(Site.objects.filter(pk=site2.pk).exists(), 'Site 2 should not have been deleted') + + def test_bulk_update_objects_unpermitted(self): + """ + PATCH a set of objects where the requesting user's object-level permissions exclude one of + them. The excluded object must be reported rather than silently omitted from the response. + """ + site1 = Site.objects.get(slug='site-1') + site2 = Site.objects.get(slug='site-2') + + obj_perm = ObjectPermission(name='Test permission', actions=['change'], constraints={'slug': 'site-1'}) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) + + data = [ + {'id': site1.pk, 'description': 'Permitted'}, + {'id': site2.pk, 'description': 'Not permitted'}, + ] + response = self.client.patch(self._get_list_url(), data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertIn('detail', response.data) + self.assertEqual(len(response.data['errors']), 1) + self.assertEqual(response.data['errors'][0]['id'], site2.pk) + + # Neither site may have been updated, including the one the user is permitted to change + site1.refresh_from_db() + site2.refresh_from_db() + self.assertEqual(site1.description, '', 'Site 1 should not have been updated') + self.assertEqual(site2.description, '', 'Site 2 should not have been updated') + + def test_bulk_delete_objects_unpermitted(self): + """ + DELETE a set of objects where the requesting user's object-level permissions exclude one of + them. The excluded object must be reported rather than the request reporting success. + """ + site1 = Site.objects.get(slug='site-1') + site2 = Site.objects.get(slug='site-2') + + obj_perm = ObjectPermission(name='Test permission', actions=['delete'], constraints={'slug': 'site-1'}) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) + + data = [{'id': site1.pk}, {'id': site2.pk}] + response = self.client.delete(self._get_list_url(), data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertIn('detail', response.data) + self.assertEqual(len(response.data['errors']), 1) + self.assertEqual(response.data['errors'][0]['id'], site2.pk) + + # Neither site may have been deleted, including the one the user is permitted to delete + self.assertTrue(Site.objects.filter(pk=site1.pk).exists(), 'Site 1 should not have been deleted') + self.assertTrue(Site.objects.filter(pk=site2.pk).exists(), 'Site 2 should not have been deleted') + + def test_bulk_update_objects_abort_request(self): + """ + PATCH a set of objects where a signal receiver raises AbortRequest for more than one of + them. Each failure must be correlated to its own object (proving the batch continues past + the first abort) and no object may be modified. + """ + # This tag may only be assigned to Regions, so assigning it to a Site raises AbortRequest + # from extras.signals.validate_assigned_tags. + restricted_tag = Tag.objects.create(name='Regions Only', slug='regions-only') + restricted_tag.object_types.set([ObjectType.objects.get_for_model(Region)]) + + site1 = Site.objects.get(slug='site-1') + site2 = Site.objects.get(slug='site-2') + + obj_perm = ObjectPermission(name='Test permission', actions=['change']) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) + self.add_permissions('extras.view_tag') + + data = [ + {'id': site1.pk, 'tags': [{'name': 'Regions Only'}]}, + {'id': site2.pk, 'tags': [{'name': 'Regions Only'}]}, + ] + response = self.client.patch(self._get_list_url(), data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertIn('detail', response.data) + self.assertEqual([e['id'] for e in response.data['errors']], [site1.pk, site2.pk]) + for error in response.data['errors']: + # Reported as a non-field error, in the same list-of-messages form as field errors + self.assertIsInstance(error['errors']['__all__'], list) + + # Neither site may have been tagged (whole batch rolled back) + self.assertFalse(site1.tags.exists(), 'Site 1 should not have been tagged') + self.assertFalse(site2.tags.exists(), 'Site 2 should not have been tagged') + + def test_bulk_delete_objects_abort_request(self): + """ + DELETE a set of objects where a protection rule blocks more than one of them. Each failure + must be correlated to its own object and no object may be deleted. A protection rule is a + rejection of the request rather than a conflict with the state of the database, so this + reports 400 -- as the single-object endpoint does for the same rule. + """ + site1 = Site.objects.get(slug='site-1') + site2 = Site.objects.get(slug='site-2') + + obj_perm = ObjectPermission(name='Test permission', actions=['delete']) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) + + # Neither site has a description, so the rule blocks both deletions via AbortRequest raised + # from core.signals.handle_deleted_object. + protection_rules = {'dcim.site': [{'description': {'required': True}}]} + data = [{'id': site1.pk}, {'id': site2.pk}] + with override_settings(PROTECTION_RULES=protection_rules): + response = self.client.delete(self._get_list_url(), data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertIn('detail', response.data) + self.assertEqual([e['id'] for e in response.data['errors']], [site1.pk, site2.pk]) + for error in response.data['errors']: + self.assertIsInstance(error['errors']['__all__'], list) + + # Neither site may have been deleted + self.assertTrue(Site.objects.filter(pk=site1.pk).exists(), 'Site 1 should not have been deleted') + self.assertTrue(Site.objects.filter(pk=site2.pk).exists(), 'Site 2 should not have been deleted') + + def test_bulk_update_objects_permission_constraint(self): + """ + PATCH a set of objects where the update would move one of them outside the requesting user's + object-level permissions. The offending object must be named, rather than the whole batch + failing with an opaque 403, and nothing may be modified. + """ + site1 = Site.objects.get(slug='site-1') + site2 = Site.objects.get(slug='site-2') + Site.objects.filter(pk__in=(site1.pk, site2.pk)).update(status=SiteStatusChoices.STATUS_ACTIVE) + + # Only active sites may be changed, so setting Site 2's status to "planned" saves the object + # and then fails _validate_objects(), which perform_update() reports as PermissionDenied. + obj_perm = ObjectPermission( + name='Test permission', + actions=['change'], + constraints={'status': SiteStatusChoices.STATUS_ACTIVE}, + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) + + data = [ + {'id': site1.pk, 'description': 'Permitted'}, + {'id': site2.pk, 'status': SiteStatusChoices.STATUS_PLANNED}, + ] + with disable_warnings('django.request'): + response = self.client.patch(self._get_list_url(), data, format='json', **self.header) + + # Still a 403, as the single-object endpoint returns, but now correlated + self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN) + self.assertIn('detail', response.data) + self.assertEqual([e['id'] for e in response.data['errors']], [site2.pk]) + self.assertIsInstance(response.data['errors'][0]['errors']['__all__'], list) + + # Neither site may have been modified, including the permitted one + site1.refresh_from_db() + site2.refresh_from_db() + self.assertEqual(site1.description, '', 'Site 1 should not have been updated') + self.assertEqual(site2.status, SiteStatusChoices.STATUS_ACTIVE, 'Site 2 should not have been updated') + + def test_bulk_update_objects_permission_constraint_and_validation_error(self): + """ + PATCH a set of objects where one entry is invalid and another is refused by object-level + permissions. Both must be reported, and the authorization failure must determine the status + code: it is the failure which would remain were the invalid entry corrected. + """ + site1 = Site.objects.get(slug='site-1') + site2 = Site.objects.get(slug='site-2') + Site.objects.filter(pk__in=(site1.pk, site2.pk)).update(status=SiteStatusChoices.STATUS_ACTIVE) + + obj_perm = ObjectPermission( + name='Test permission', + actions=['change'], + constraints={'status': SiteStatusChoices.STATUS_ACTIVE}, + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) + + data = [ + {'id': site1.pk, 'status': 'not-a-valid-status'}, # Fails validation (400) + {'id': site2.pk, 'status': SiteStatusChoices.STATUS_PLANNED}, # Not permitted (403) + ] + with disable_warnings('django.request'): + response = self.client.patch(self._get_list_url(), data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN) + self.assertEqual([e['id'] for e in response.data['errors']], [site1.pk, site2.pk]) + + site1.refresh_from_db() + site2.refresh_from_db() + self.assertEqual(site1.status, SiteStatusChoices.STATUS_ACTIVE) + self.assertEqual(site2.status, SiteStatusChoices.STATUS_ACTIVE) + + def test_bulk_create_objects_permission_constraint(self): + """ + POST a set of objects where one falls outside the requesting user's object-level permissions. + The offending object must be correlated by its position, and nothing may be created. + """ + obj_perm = ObjectPermission( + name='Test permission', + actions=['add'], + constraints={'status': SiteStatusChoices.STATUS_ACTIVE}, + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) + + initial_count = self._get_queryset().count() + data = [ + {'name': 'Site 20', 'slug': 'site-20', 'status': SiteStatusChoices.STATUS_ACTIVE}, + {'name': 'Site 21', 'slug': 'site-21', 'status': SiteStatusChoices.STATUS_PLANNED}, + ] + with disable_warnings('django.request'): + response = self.client.post(self._get_list_url(), data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN) + self.assertEqual([e['index'] for e in response.data['errors']], [1]) + self.assertIsInstance(response.data['errors'][0]['errors']['__all__'], list) + + self.assertEqual( + self._get_queryset().count(), initial_count, + 'No objects should be created when any sibling is not permitted', + ) + + def test_bulk_delete_objects_conflict_and_abort_request(self): + """ + DELETE a set of objects where one is blocked by a dependent object and another by a + protection rule. Both failures must be reported, and the dependency conflict must determine + the status code: it is the failure which would remain were the request itself corrected. + """ + site1 = Site.objects.get(slug='site-1') + site2 = Site.objects.get(slug='site-2') + + obj_perm = ObjectPermission(name='Test permission', actions=['delete']) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) + + # Site 1 is blocked by a dependent Device (ProtectedError); Site 2 is blocked by the + # protection rule below, as it has no description (AbortRequest). Site 1 is given a + # description so that only one of the two failure modes applies to it. + create_test_device('Protected Device', site=site1) + site1.description = 'Has a description' + site1.save() + + protection_rules = {'dcim.site': [{'description': {'required': True}}]} + data = [{'id': site1.pk}, {'id': site2.pk}] + with override_settings(PROTECTION_RULES=protection_rules): + response = self.client.delete(self._get_list_url(), data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_409_CONFLICT) + self.assertEqual([e['id'] for e in response.data['errors']], [site1.pk, site2.pk]) + for error in response.data['errors']: + self.assertIsInstance(error['errors']['__all__'], list) + + # Neither site may have been deleted + self.assertTrue(Site.objects.filter(pk=site1.pk).exists(), 'Site 1 should not have been deleted') + self.assertTrue(Site.objects.filter(pk=site2.pk).exists(), 'Site 2 should not have been deleted') + class LocationTestCase(APIViewTestCases.APIViewTestCase): model = Location @@ -648,6 +1147,8 @@ class RackTypeTestCase(APIViewTestCases.APIViewTestCase): brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'rack_count', 'slug', 'url'] bulk_update_data = { 'description': 'new description', + 'cooling_capability': RackCoolingCapabilityChoices.CAPABILITY_HYBRID, + 'cooling_capacity': 50, } user_permissions = ('dcim.view_manufacturer',) @@ -687,12 +1188,15 @@ class RackTypeTestCase(APIViewTestCases.APIViewTestCase): 'model': 'Rack Type 4', 'slug': 'rack-type-4', 'form_factor': RackFormFactorChoices.TYPE_CABINET, + 'cooling_capability': RackCoolingCapabilityChoices.CAPABILITY_LIQUID_ONLY, + 'cooling_capacity': 80, }, { 'manufacturer': manufacturers[1].pk, 'model': 'Rack Type 5', 'slug': 'rack-type-5', 'form_factor': RackFormFactorChoices.TYPE_CABINET, + 'cooling_capability': RackCoolingCapabilityChoices.CAPABILITY_HYBRID, }, { 'manufacturer': manufacturers[1].pk, @@ -941,6 +1445,7 @@ class DeviceTypeTestCase(APIViewTestCases.APIViewTestCase): brief_fields = ['description', 'device_count', 'display', 'id', 'manufacturer', 'model', 'slug', 'url'] bulk_update_data = { 'part_number': 'ABC123', + 'end_of_life': '2030-01-01', } user_permissions = ('dcim.view_manufacturer', ) @@ -972,6 +1477,7 @@ class DeviceTypeTestCase(APIViewTestCases.APIViewTestCase): 'model': 'Device Type 5', 'slug': 'device-type-5', 'u_height': 0.5, + 'end_of_life': '2035-06-30', }, { 'manufacturer': manufacturers[1].pk, @@ -987,6 +1493,7 @@ class ModuleTypeTestCase(APIViewTestCases.APIViewTestCase): brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'module_count', 'profile', 'url'] bulk_update_data = { 'part_number': 'ABC123', + 'end_of_life': '2030-01-01', } user_permissions = ('dcim.view_manufacturer', ) @@ -1006,14 +1513,25 @@ class ModuleTypeTestCase(APIViewTestCases.APIViewTestCase): ) ModuleType.objects.bulk_create(module_types) + module_bay_types = ( + ModuleBayType(manufacturer=manufacturers[0], name='Module Bay Type 1', slug='module-bay-type-1'), + ModuleBayType(manufacturer=manufacturers[0], name='Module Bay Type 2', slug='module-bay-type-2'), + ) + ModuleBayType.objects.bulk_create(module_bay_types) + for module_type in module_types: + module_type.module_bay_types.set(module_bay_types) + cls.create_data = [ { 'manufacturer': manufacturers[1].pk, 'model': 'Module Type 4', + 'module_bay_types': [module_bay_types[0].pk, module_bay_types[1].pk], }, { 'manufacturer': manufacturers[1].pk, 'model': 'Module Type 5', + 'end_of_life': '2035-06-30', + 'module_bay_types': [module_bay_types[0].pk], }, { 'manufacturer': manufacturers[1].pk, @@ -1086,6 +1604,47 @@ class ModuleTypeProfileTestCase(APIViewTestCases.APIViewTestCase): ModuleTypeProfile.objects.bulk_create(module_type_profiles) +class ModuleBayTypeTestCase(APIViewTestCases.APIViewTestCase): + model = ModuleBayType + brief_fields = ['color', 'description', 'display', 'id', 'manufacturer', 'name', 'slug', 'url'] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + manufacturers = ( + Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), + Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), + ) + Manufacturer.objects.bulk_create(manufacturers) + + module_bay_types = ( + ModuleBayType(manufacturer=manufacturers[0], name='Module Bay Type 1', slug='module-bay-type-1'), + ModuleBayType(manufacturer=manufacturers[0], name='Module Bay Type 2', slug='module-bay-type-2'), + ModuleBayType(manufacturer=manufacturers[0], name='Module Bay Type 3', slug='module-bay-type-3'), + ) + ModuleBayType.objects.bulk_create(module_bay_types) + + cls.create_data = [ + { + 'manufacturer': manufacturers[1].pk, + 'name': 'Module Bay Type 4', + 'slug': 'module-bay-type-4', + }, + { + 'manufacturer': manufacturers[1].pk, + 'name': 'Module Bay Type 5', + 'slug': 'module-bay-type-5', + }, + { + 'manufacturer': manufacturers[1].pk, + 'name': 'Module Bay Type 6', + 'slug': 'module-bay-type-6', + }, + ] + + class ConsolePortTemplateTestCase(APIViewTestCases.APIViewTestCase): model = ConsolePortTemplate brief_fields = ['description', 'display', 'id', 'name', 'url'] @@ -1296,9 +1855,11 @@ class InterfaceTemplateTestCase(APIViewTestCases.APIViewTestCase): interface_templates = ( InterfaceTemplate(device_type=devicetype, name='Interface Template 1', type='1000base-t'), InterfaceTemplate(device_type=devicetype, name='Interface Template 2', type='1000base-t'), - InterfaceTemplate(device_type=devicetype, name='Interface Template 3', type='1000base-t'), + # Interface Template 3 is channelized, so that channel subinterface templates may be bound to it + InterfaceTemplate(device_type=devicetype, name='Interface Template 3', type='1000base-t', channels=4), ) InterfaceTemplate.objects.bulk_create(interface_templates) + channelized_parent = interface_templates[2] cls.create_data = [ { @@ -1321,6 +1882,21 @@ class InterfaceTemplateTestCase(APIViewTestCases.APIViewTestCase): 'name': 'Interface Template 7', 'type': '1000base-t', }, + { + # A channelized parent template + 'device_type': devicetype.pk, + 'name': 'Interface Template 8', + 'type': InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, + 'channels': 4, + }, + { + # A channel subinterface template bound to a channelized parent + 'device_type': devicetype.pk, + 'name': 'Interface Template 9', + 'type': InterfaceTypeChoices.TYPE_CHANNEL, + 'parent': channelized_parent.pk, + 'channel_id': 1, + }, ] @@ -1582,15 +2158,25 @@ class ModuleBayTemplateTestCase(APIViewTestCases.APIViewTestCase): ) ModuleBayTemplate.objects.bulk_create(module_bay_templates) + module_bay_types = ( + ModuleBayType(manufacturer=manufacturer, name='Module Bay Type 1', slug='module-bay-type-1'), + ModuleBayType(manufacturer=manufacturer, name='Module Bay Type 2', slug='module-bay-type-2'), + ) + ModuleBayType.objects.bulk_create(module_bay_types) + for module_bay_template in module_bay_templates: + module_bay_template.module_bay_types.set(module_bay_types) + cls.create_data = [ { 'device_type': devicetype.pk, 'name': 'Module Bay Template 4', 'enabled': False, + 'module_bay_types': [module_bay_types[0].pk, module_bay_types[1].pk], }, { 'device_type': devicetype.pk, 'name': 'Module Bay Template 5', + 'module_bay_types': [module_bay_types[0].pk], }, { 'device_type': devicetype.pk, @@ -1886,16 +2472,6 @@ class DeviceTestCase(APIViewTestCases.APIViewTestCase): self.assertEqual(response.data['results'][0].get('config_context', {}).get('A'), 1) - def test_config_context_excluded(self): - """ - Check that config context data can be excluded by passing ?exclude=config_context. - """ - self.add_permissions('dcim.view_device') - url = reverse('dcim-api:device-list') + '?exclude=config_context' - response = self.client.get(url, **self.header) - - self.assertFalse('config_context' in response.data['results'][0]) - def test_unique_name_per_site_constraint(self): """ Check that creating a device with a duplicate name within a site fails. @@ -2047,7 +2623,7 @@ class DeviceTestCase(APIViewTestCases.APIViewTestCase): self.add_permissions('dcim.view_device', 'ipam.view_ipaddress') response = self.client.get( - f'{self._get_detail_url(device)}?exclude=config_context', + self._get_detail_url(device), **self.header, ) self.assertHttpStatus(response, status.HTTP_200_OK) @@ -2075,7 +2651,7 @@ class DeviceTestCase(APIViewTestCases.APIViewTestCase): self.add_permissions('dcim.view_device', 'ipam.view_ipaddress') response = self.client.get( - f'{self._get_detail_url(device)}?exclude=config_context', + self._get_detail_url(device), **self.header, ) self.assertHttpStatus(response, status.HTTP_200_OK) @@ -2171,6 +2747,73 @@ class DeviceTestCase(APIViewTestCases.APIViewTestCase): response = self.client.post(url, {'config_template_id': override_template.pk}, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + def test_bulk_create_objects_validation_error(self): + """ + POST a set of Device objects where the first passes and the second fails validation. The + response should report only the failed object, and no objects should be created despite + the first item passing (atomic rollback). + """ + obj_perm = ObjectPermission(name='Test permission', actions=['add']) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) + + initial_count = self._get_queryset().count() + # First item is valid; second is empty (missing required fields) and will fail + response = self.client.post( + self._get_list_url(), + [self.create_data[0], {}], + format='json', + **self.header, + ) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + self._get_queryset().count(), initial_count, + 'No objects should be created when any sibling fails validation', + ) + self.assertIn('detail', response.data) + self.assertIn('errors', response.data) + self.assertEqual(len(response.data['errors']), 1) + # Second item failed validation — first item succeeded so it's omitted + self.assertEqual(response.data['errors'][0]['index'], 1) + self.assertIn('errors', response.data['errors'][0]) + + def test_bulk_create_objects_abort_request(self): + """ + POST a set of Device objects where a signal receiver raises AbortRequest for more than one + of them. Each failure must be correlated to its position in the request (proving the batch + continues past the first abort) and no object may be created. + """ + # This tag may only be assigned to Regions, so assigning it to a Device raises AbortRequest + # from extras.signals.validate_assigned_tags. + restricted_tag = Tag.objects.create(name='Regions Only', slug='regions-only') + restricted_tag.object_types.set([ObjectType.objects.get_for_model(Region)]) + + obj_perm = ObjectPermission(name='Test permission', actions=['add']) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) + self.add_permissions('extras.view_tag') + + initial_count = self._get_queryset().count() + data = [ + {**self.create_data[0], 'tags': [{'name': 'Regions Only'}]}, + {**self.create_data[1], 'tags': [{'name': 'Regions Only'}]}, + ] + response = self.client.post(self._get_list_url(), data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertIn('detail', response.data) + self.assertEqual([e['index'] for e in response.data['errors']], [0, 1]) + for error in response.data['errors']: + self.assertIsInstance(error['errors']['__all__'], list) + + self.assertEqual( + self._get_queryset().count(), initial_count, + 'No objects should be created when any sibling is aborted', + ) + class ModuleTestCase(APIViewTestCases.APIViewTestCase): model = Module @@ -2244,6 +2887,62 @@ class ModuleTestCase(APIViewTestCases.APIViewTestCase): }, ] + cls.update_data = { + 'device': device.pk, + 'module_bay': module_bays[3].pk, + 'module_type': module_types[0].pk, + 'status': 'active', + 'serial': 'ABC123', + } + + def test_is_bay_compatible_flag(self): + """ + is_bay_compatible should be True when no bay types are set, and False when the + bay's types and the module type's types are both set but share no common members. + """ + self.add_permissions('dcim.view_module') + manufacturer = Manufacturer.objects.get(name='Generic') + device = create_test_device('Compat Test Device') + + bay_type_a = ModuleBayType.objects.create(manufacturer=manufacturer, name='Bay Type A', slug='bay-type-a') + bay_type_b = ModuleBayType.objects.create(manufacturer=manufacturer, name='Bay Type B', slug='bay-type-b') + + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Compat Module Type') + module_type.module_bay_types.set([bay_type_a]) + + compatible_bay = ModuleBay.objects.create(device=device, name='Compatible Bay') + compatible_bay.module_bay_types.set([bay_type_a]) + + incompatible_bay = ModuleBay.objects.create(device=device, name='Incompatible Bay') + incompatible_bay.module_bay_types.set([bay_type_b]) + + unconstrained_bay = ModuleBay.objects.create(device=device, name='Unconstrained Bay') + + compatible_module = Module.objects.create( + device=device, module_bay=compatible_bay, module_type=module_type + ) + incompatible_module = Module.objects.create( + device=device, module_bay=incompatible_bay, module_type=module_type + ) + unconstrained_module = Module.objects.create( + device=device, module_bay=unconstrained_bay, module_type=module_type + ) + + url = reverse('dcim-api:module-detail', kwargs={'pk': compatible_module.pk}) + response = self.client.get(url, **self.header) + self.assertHttpStatus(response, 200) + self.assertTrue(response.data['is_bay_compatible']) + + url = reverse('dcim-api:module-detail', kwargs={'pk': incompatible_module.pk}) + response = self.client.get(url, **self.header) + self.assertHttpStatus(response, 200) + self.assertFalse(response.data['is_bay_compatible']) + + url = reverse('dcim-api:module-detail', kwargs={'pk': unconstrained_module.pk}) + response = self.client.get(url, **self.header) + self.assertHttpStatus(response, 200) + self.assertTrue(response.data['is_bay_compatible']) + def test_replicate_components(self): """ Installing a module with replicate_components=True (the default) should create @@ -2506,6 +3205,111 @@ class ModuleTestCase(APIViewTestCases.APIViewTestCase): self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(len(response.data['results']), 1) + def test_patch_module_bay_derives_device(self): + self.add_permissions('dcim.change_module') + module = Module.objects.order_by('pk').first() + device_b = create_test_device('Module Move Device B') + bay_b = ModuleBay.objects.create(device=device_b, name='Module Move Bay B1') + + url = reverse('dcim-api:module-detail', kwargs={'pk': module.pk}) + response = self.client.patch(url, {'module_bay': bay_b.pk}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + module.refresh_from_db() + self.assertEqual(module.device, device_b) + self.assertEqual(module.module_bay, bay_b) + + def test_patch_device_and_module_bay_mismatch_fails(self): + self.add_permissions('dcim.change_module') + module = Module.objects.order_by('pk').first() + device_b = create_test_device('Module Move Device B') + same_device_bay = ModuleBay.objects.create(device=module.device, name='Module Move Bay A9') + + url = reverse('dcim-api:module-detail', kwargs={'pk': module.pk}) + response = self.client.patch( + url, {'device': device_b.pk, 'module_bay': same_device_bay.pk}, format='json', **self.header + ) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + + def test_patch_module_type_with_move_fails(self): + self.add_permissions('dcim.change_module') + module = Module.objects.order_by('pk').first() + empty_bay = ModuleBay.objects.filter( + device=module.device, installed_module__isnull=True + ).first() + other_type = ModuleType.objects.exclude(pk=module.module_type_id).first() + + url = reverse('dcim-api:module-detail', kwargs={'pk': module.pk}) + response = self.client.patch( + url, {'module_bay': empty_bay.pk, 'module_type': other_type.pk}, format='json', **self.header + ) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertIn('module_type', response.data) + + def test_patch_occupied_bay_fails(self): + self.add_permissions('dcim.change_module') + module_1, module_2 = Module.objects.order_by('pk')[:2] + + url = reverse('dcim-api:module-detail', kwargs={'pk': module_1.pk}) + response = self.client.patch( + url, {'module_bay': module_2.module_bay_id}, format='json', **self.header + ) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertIn('module_bay', response.data) + + def test_patch_cross_device_move_blocked_by_ip_address(self): + self.add_permissions('dcim.change_module') + module = Module.objects.order_by('pk').first() + interface = Interface.objects.create( + device=module.device, module=module, name='Move Test Interface 1', + type=InterfaceTypeChoices.TYPE_1GE_FIXED, + ) + IPAddress.objects.create(address='192.0.2.10/32', assigned_object=interface) + device_b = create_test_device('Module Move Device B') + bay_b = ModuleBay.objects.create(device=device_b, name='Module Move Bay B1') + + url = reverse('dcim-api:module-detail', kwargs={'pk': module.pk}) + response = self.client.patch(url, {'module_bay': bay_b.pk}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + + def test_create_with_conflicting_cooling_component_fails(self): + """ + A cooling component name collision must be reported as a validation error rather + than raising an IntegrityError from the replication insert. See netbox#15289. + """ + self.add_permissions('dcim.add_module') + device = create_test_device('Cooling Conflict Device') + module_bay = ModuleBay.objects.create(device=device, name='Cooling Conflict Bay') + module_type = ModuleType.objects.create( + manufacturer=Manufacturer.objects.first(), model='Cooled API Type' + ) + CoolingIntakeTemplate.objects.create(module_type=module_type, name='Intake 1') + CoolingIntake.objects.create(device=device, name='Intake 1') + + response = self.client.post(reverse('dcim-api:module-list'), { + 'device': device.pk, + 'module_bay': module_bay.pk, + 'module_type': module_type.pk, + 'status': 'active', + }, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertIn('Intake 1', str(response.data)) + + def test_patch_cross_device_move_blocked_by_split_cooling_relation(self): + self.add_permissions('dcim.change_module') + module = Module.objects.order_by('pk').first() + intake = CoolingIntake.objects.create( + device=module.device, module=module, name='Move Test Intake 1' + ) + CoolingOutflow.objects.create( + device=module.device, name='Move Test Chassis Outflow', cooling_intake=intake + ) + device_b = create_test_device('Module Move Device B') + bay_b = ModuleBay.objects.create(device=device_b, name='Module Move Bay B1') + + url = reverse('dcim-api:module-detail', kwargs={'pk': module.pk}) + response = self.client.patch(url, {'module_bay': bay_b.pk}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + class ConsolePortTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsolePort @@ -2702,9 +3506,11 @@ class InterfaceTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTest interfaces = ( Interface(device=device, name='Interface 1', type='1000base-t'), Interface(device=device, name='Interface 2', type='1000base-t'), - Interface(device=device, name='Interface 3', type='1000base-t'), + # Interface 3 is channelized, so that channel subinterfaces may be bound to it + Interface(device=device, name='Interface 3', type='1000base-t', channels=4), ) Interface.objects.bulk_create(interfaces) + channelized_parent = interfaces[2] vdcs = ( VirtualDeviceContext(name='VDC 1', identifier=1, device=device), @@ -2794,6 +3600,21 @@ class InterfaceTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTest 'rf_channel': "", 'qinq_svlan': vlans[3].pk, }, + { + # A channelized parent interface + 'device': device.pk, + 'name': 'Interface 9', + 'type': InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, + 'channels': 4, + }, + { + # A channel subinterface bound to a channelized parent + 'device': device.pk, + 'name': 'Interface 10', + 'type': InterfaceTypeChoices.TYPE_CHANNEL, + 'parent': channelized_parent.pk, + 'channel_id': 1, + }, ] def _perform_interface_test_with_invalid_data(self, mode: str = None, invalid_data: dict = {}): @@ -2892,6 +3713,337 @@ class InterfaceTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTest # Tagged-all mode, qinq service vlan self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED_ALL, invalid_data) + def test_mac_address_create(self): + """ + Creating an interface with mac_address creates the primary MACAddress in one request. + """ + self.add_permissions('dcim.add_interface', 'dcim.add_macaddress') + device = Device.objects.first() + data = { + 'device': device.pk, + 'name': 'Interface MAC Create', + 'type': '1000base-t', + 'mac_address': 'AA:BB:CC:DD:EE:FF', + } + response = self.client.post(self._get_list_url(), data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + iface = Interface.objects.get(pk=response.data['id']) + self.assertIsNotNone(iface.primary_mac_address) + self.assertEqual(str(iface.primary_mac_address.mac_address).upper(), 'AA:BB:CC:DD:EE:FF') + self.assertEqual(iface.primary_mac_address.assigned_object, iface) + + def test_mac_address_update(self): + """ + Patching mac_address creates/updates the primary MACAddress in one request. + """ + self.add_permissions('dcim.change_interface', 'dcim.add_macaddress', 'dcim.change_macaddress') + iface = Interface.objects.first() + url = self._get_detail_url(iface) + + # Set a new primary MAC via mac_address shortcut + response = self.client.patch(url, {'mac_address': '11:22:33:44:55:66'}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + iface.refresh_from_db() + self.assertIsNotNone(iface.primary_mac_address) + self.assertEqual(str(iface.primary_mac_address.mac_address).upper(), '11:22:33:44:55:66') + + # Update the MAC to a new value + response = self.client.patch(url, {'mac_address': 'AA:BB:CC:DD:EE:FF'}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + iface.refresh_from_db() + self.assertEqual(str(iface.primary_mac_address.mac_address).upper(), 'AA:BB:CC:DD:EE:FF') + + # Clear the primary MAC by sending null + response = self.client.patch(url, {'mac_address': None}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + iface.refresh_from_db() + self.assertIsNone(iface.primary_mac_address) + + def test_mac_address_invalid(self): + """ + Sending an invalid MAC address string returns a 400 error. + """ + self.add_permissions('dcim.add_interface', 'dcim.add_macaddress') + device = Device.objects.first() + data = { + 'device': device.pk, + 'name': 'Interface MAC Bad', + 'type': '1000base-t', + 'mac_address': 'not-a-mac', + } + response = self.client.post(self._get_list_url(), data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertIn('mac_address', response.data) + + def test_mac_address_find_or_create(self): + """ + Patching mac_address with a MAC that already exists on the interface promotes it to primary + without creating a duplicate MACAddress record. + """ + self.add_permissions('dcim.change_interface', 'dcim.add_macaddress', 'dcim.change_macaddress') + iface = Interface.objects.first() + + # Pre-create two MACs assigned to this interface + mac1 = MACAddress.objects.create(mac_address='CC:DD:EE:FF:00:01', assigned_object=iface) + mac2 = MACAddress.objects.create(mac_address='CC:DD:EE:FF:00:02', assigned_object=iface) + iface.primary_mac_address = mac1 + iface.save() + + mac_count_before = iface.mac_addresses.count() + url = self._get_detail_url(iface) + + # PATCH with mac2's address — should promote mac2, not create a new record + response = self.client.patch(url, {'mac_address': 'CC:DD:EE:FF:00:02'}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + + iface.refresh_from_db() + self.assertEqual(iface.primary_mac_address.pk, mac2.pk) + self.assertEqual(iface.mac_addresses.count(), mac_count_before) + + def test_channel_binding_survives_combined_mac_address_update(self): + """ + PATCHing channel_id/parent and mac_address together must not let the mac_address shortcut's + second instance.save() (see MACAddressShortcutMixin.update()) write the interface's + pre-propagation, stale in-memory cable_id/_path back over the cable and path just mirrored + from its newly assigned parent by update_channelized_cable_paths() -- and, on detach, must + not resurrect the stale in-memory values that update() clears via a queryset .update() + rather than a save() on this same instance. + """ + self.add_permissions('dcim.change_interface', 'dcim.add_macaddress') + device = Device.objects.first() + channelized_parent = Interface.objects.get(device=device, name='Interface 3') + far_end = Interface.objects.create(device=device, name='Far End', type='1000base-t') + cable = Cable( + profile=CableProfileChoices.BREAKOUT_1C4P_4C1P, + a_terminations=[channelized_parent], + b_terminations=[far_end], + ) + cable.full_clean() + cable.save() + + child = Interface.objects.get(device=device, name='Interface 1') + url = self._get_detail_url(child) + + # Attach: bind the channel and set mac_address in the same request. + data = { + 'parent': channelized_parent.pk, + 'channel_id': 1, + 'type': 'channel', + 'mac_address': 'AA:BB:CC:DD:EE:01', + } + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + + child.refresh_from_db() + self.assertEqual( + child.cable_id, cable.pk, + "the mac_address update's second save() clobbered the cable just mirrored from the parent", + ) + self.assertEqual(child.cable_positions, [1]) + self.assertIsNotNone( + child._path_id, "the mac_address update's second save() clobbered the path traced on attach", + ) + self.assertIsNotNone(child.primary_mac_address) + self.assertEqual(str(child.primary_mac_address.mac_address).upper(), 'AA:BB:CC:DD:EE:01') + + # Detach: clear the channel binding and change mac_address again in the same request. + data = { + 'parent': None, + 'channel_id': None, + 'type': '1000base-t', + 'mac_address': 'AA:BB:CC:DD:EE:02', + } + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + + child.refresh_from_db() + self.assertIsNone( + child.cable_id, "the mac_address update's second save() resurrected the cleared cable on detach", + ) + self.assertIsNone( + child._path_id, "the mac_address update's second save() resurrected the cleared path on detach", + ) + self.assertEqual(str(child.primary_mac_address.mac_address).upper(), 'AA:BB:CC:DD:EE:02') + + def test_mac_address_conflicts_with_primary_mac_address(self): + """ + Supplying both the mac_address shortcut and primary_mac_address in one request is rejected + rather than letting one silently win. + """ + self.add_permissions('dcim.change_interface', 'dcim.add_macaddress', 'dcim.change_macaddress') + iface = Interface.objects.first() + mac = MACAddress.objects.create(mac_address='DD:EE:FF:00:11:22', assigned_object=iface) + url = self._get_detail_url(iface) + + response = self.client.patch( + url, + {'mac_address': 'DD:EE:FF:00:11:33', 'primary_mac_address': {'mac_address': str(mac.mac_address)}}, + format='json', + **self.header + ) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + + def test_mac_address_conflicts_with_explicit_null_primary(self): + """ + The mac_address shortcut alongside an explicit primary_mac_address=null is a conflict (set vs + clear) and is rejected, not silently resolved in the shortcut's favor. + """ + self.add_permissions('dcim.change_interface', 'dcim.add_macaddress') + iface = Interface.objects.first() + url = self._get_detail_url(iface) + + response = self.client.patch( + url, + {'mac_address': 'DD:EE:FF:00:11:44', 'primary_mac_address': None}, + format='json', + **self.header + ) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + + def test_mac_address_agreeing_with_primary_mac_address_is_accepted(self): + """ + A read-modify-write round-trip echoes both readable fields with matching values. When the + shortcut and primary_mac_address designate the same MAC, the request is accepted. + """ + self.add_permissions( + 'dcim.change_interface', 'dcim.add_macaddress', 'dcim.change_macaddress', 'dcim.view_macaddress' + ) + iface = Interface.objects.first() + mac = MACAddress.objects.create(mac_address='DD:EE:FF:00:11:66', assigned_object=iface) + iface.primary_mac_address = mac + iface.save() + url = self._get_detail_url(iface) + + response = self.client.patch( + url, + { + 'mac_address': str(mac.mac_address), + 'primary_mac_address': {'mac_address': str(mac.mac_address)}, + 'description': 'round-trip edit', + }, + format='json', + **self.header + ) + self.assertHttpStatus(response, status.HTTP_200_OK) + iface.refresh_from_db() + self.assertEqual(iface.primary_mac_address_id, mac.pk) + self.assertEqual(iface.description, 'round-trip edit') + + def test_null_mac_fields_round_trip_accepted(self): + """ + An interface with no primary MAC round-trips both fields as null; echoing both back is accepted. + """ + self.add_permissions('dcim.change_interface') + iface = Interface.objects.first() + iface.primary_mac_address = None + iface.save() + url = self._get_detail_url(iface) + + response = self.client.patch( + url, + {'mac_address': None, 'primary_mac_address': None, 'description': 'null round-trip'}, + format='json', + **self.header + ) + self.assertHttpStatus(response, status.HTTP_200_OK) + + def test_combined_update_changelog_does_not_reattribute_other_fields(self): + """ + A combined PATCH of an unrelated field plus the mac_address shortcut produces two ObjectChange + rows (the fields save, then the primary-MAC save). The MAC change's row must record the state + after the field save as its prechange, so the unrelated field edit isn't re-reported as part of + the MAC change. + """ + self.add_permissions('dcim.change_interface', 'dcim.add_macaddress') + iface = Interface.objects.first() + iface.description = 'original' + iface.primary_mac_address = None + iface.save() + url = self._get_detail_url(iface) + + response = self.client.patch( + url, + {'description': 'updated', 'mac_address': 'AA:BB:CC:DD:EE:10'}, + format='json', + **self.header + ) + self.assertHttpStatus(response, status.HTTP_200_OK) + + changes = ObjectChange.objects.filter( + action=ObjectChangeActionChoices.ACTION_UPDATE, + changed_object_type=ContentType.objects.get_for_model(Interface), + changed_object_id=iface.pk, + ).order_by('pk') + # The MAC change is the row whose postchange records the new primary MAC. + mac_change = changes.filter(postchange_data__primary_mac_address__isnull=False).last() + self.assertIsNotNone(mac_change) + # Its prechange must reflect the already-saved description, so the field edit isn't re-attributed. + self.assertEqual(mac_change.prechange_data['description'], 'updated') + + def test_primary_mac_address_must_belong_to_interface(self): + """ + Setting primary_mac_address (bypassing the shortcut op) to a MAC not assigned to this interface + is rejected on update, so the primary MAC can't dangle outside the interface's own MAC set. + """ + self.add_permissions('dcim.change_interface', 'dcim.change_macaddress') + iface = Interface.objects.first() + unassigned = MACAddress.objects.create(mac_address='DD:EE:FF:00:11:55') + url = self._get_detail_url(iface) + + response = self.client.patch( + url, + {'primary_mac_address': {'mac_address': str(unassigned.mac_address)}}, + format='json', + **self.header + ) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + iface.refresh_from_db() + self.assertIsNone(iface.primary_mac_address) + + def test_create_with_unassigned_primary_mac_address(self): + """ + Creating an interface with a nested, as-yet-unassigned primary_mac_address is allowed: the + clean() invariant is skipped while adding, and the post_save signal assigns the MAC to the new + interface. This pins the create-path carve-out against a future signal regression. + """ + self.add_permissions( + 'dcim.add_interface', 'dcim.add_macaddress', 'dcim.change_macaddress', 'dcim.view_macaddress' + ) + device = Device.objects.first() + unassigned = MACAddress.objects.create(mac_address='DD:EE:FF:00:11:77') + + data = { + 'device': device.pk, + 'name': 'Interface With Primary MAC', + 'type': '1000base-t', + 'primary_mac_address': {'mac_address': str(unassigned.mac_address)}, + } + response = self.client.post(self._get_list_url(), data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + + iface = Interface.objects.get(pk=response.data['id']) + self.assertEqual(iface.primary_mac_address_id, unassigned.pk) + # The signal assigned the MAC to the new interface, so it isn't a dangling primary. + unassigned.refresh_from_db() + self.assertEqual(unassigned.assigned_object, iface) + + @override_settings(CUSTOM_VALIDATORS={'dcim.macaddress': [{'mac_address': {'regex': '^AA:'}}]}) + def test_mac_address_custom_validation_returns_400(self): + """ + A MAC that fails a custom validator on creation returns a 400, not a 500 (the model + ValidationError raised inside the serializer is translated to a DRF error). + """ + self.add_permissions('dcim.add_interface', 'dcim.add_macaddress') + device = Device.objects.first() + data = { + 'device': device.pk, + 'name': 'Interface Custom Validation', + 'type': '1000base-t', + 'mac_address': 'BB:CC:DD:EE:FF:00', + } + response = self.client.post(self._get_list_url(), data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + class FrontPortTestCase(APIViewTestCases.APIViewTestCase): model = FrontPort @@ -3153,15 +4305,25 @@ class ModuleBayTestCase(APIViewTestCases.APIViewTestCase): for module_bay in module_bays: module_bay.save() + module_bay_types = ( + ModuleBayType(manufacturer=manufacturer, name='Module Bay Type 1', slug='module-bay-type-1'), + ModuleBayType(manufacturer=manufacturer, name='Module Bay Type 2', slug='module-bay-type-2'), + ) + ModuleBayType.objects.bulk_create(module_bay_types) + for module_bay in module_bays: + module_bay.module_bay_types.set(module_bay_types) + cls.create_data = [ { 'device': device.pk, 'name': 'Device Bay 4', 'enabled': False, + 'module_bay_types': [module_bay_types[0].pk, module_bay_types[1].pk], }, { 'device': device.pk, 'name': 'Device Bay 5', + 'module_bay_types': [module_bay_types[0].pk], }, { 'device': device.pk, @@ -3169,6 +4331,85 @@ class ModuleBayTestCase(APIViewTestCases.APIViewTestCase): }, ] + def test_is_module_compatible_flag(self): + """ + is_module_compatible should be True when no bay types restrict the bay, and False + when the bay's types and the installed module type's types share no common members. + """ + self.add_permissions('dcim.view_modulebay', 'dcim.view_module', 'dcim.view_moduletype') + manufacturer = Manufacturer.objects.create( + name='Compat Manufacturer', slug='compat-manufacturer' + ) + device = create_test_device('Compat Bay Test Device') + + bay_type_a = ModuleBayType.objects.create( + manufacturer=manufacturer, name='Compat Bay Type A', slug='compat-bay-type-a' + ) + bay_type_b = ModuleBayType.objects.create( + manufacturer=manufacturer, name='Compat Bay Type B', slug='compat-bay-type-b' + ) + + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Compat MT') + module_type.module_bay_types.set([bay_type_a]) + + compatible_bay = ModuleBay.objects.create(device=device, name='Compat Bay C') + compatible_bay.module_bay_types.set([bay_type_a]) + Module.objects.create(device=device, module_bay=compatible_bay, module_type=module_type) + + incompatible_bay = ModuleBay.objects.create(device=device, name='Compat Bay I') + incompatible_bay.module_bay_types.set([bay_type_b]) + Module.objects.create(device=device, module_bay=incompatible_bay, module_type=module_type) + + empty_bay = ModuleBay.objects.create(device=device, name='Compat Bay E') + + url = reverse('dcim-api:modulebay-detail', kwargs={'pk': compatible_bay.pk}) + response = self.client.get(url, **self.header) + self.assertHttpStatus(response, 200) + self.assertTrue(response.data['is_module_compatible']) + + url = reverse('dcim-api:modulebay-detail', kwargs={'pk': incompatible_bay.pk}) + response = self.client.get(url, **self.header) + self.assertHttpStatus(response, 200) + self.assertFalse(response.data['is_module_compatible']) + + url = reverse('dcim-api:modulebay-detail', kwargs={'pk': empty_bay.pk}) + response = self.client.get(url, **self.header) + self.assertHttpStatus(response, 200) + self.assertTrue(response.data['is_module_compatible']) + + def test_module_bay_types_write(self): + """ + module_bay_types accepts a list of primary keys, renders as nested objects, is cleared by an + empty list, and rejects an unknown primary key without altering the existing assignment. + """ + self.add_permissions('dcim.view_modulebay', 'dcim.change_modulebay') + bay_type = ModuleBayType.objects.first() + module_bay = self._get_queryset().first() + url = self._get_detail_url(module_bay) + self.assertEqual(module_bay.module_bay_types.count(), 2) + + # Assigning by primary key replaces the existing set rather than adding to it + response = self.client.patch(url, {'module_bay_types': [bay_type.pk]}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertListEqual([mbt.pk for mbt in module_bay.module_bay_types.all()], [bay_type.pk]) + + # The response renders nested objects, not bare primary keys + self.assertIsInstance(response.data['module_bay_types'][0], dict) + self.assertEqual(response.data['module_bay_types'][0]['id'], bay_type.pk) + self.assertEqual(response.data['module_bay_types'][0]['name'], bay_type.name) + + # An unknown primary key is rejected without altering the existing assignment + bad_pk = ModuleBayType.objects.order_by('pk').last().pk + 1 + response = self.client.patch(url, {'module_bay_types': [bad_pk]}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertIn('module_bay_types', response.data) + self.assertListEqual([mbt.pk for mbt in module_bay.module_bay_types.all()], [bay_type.pk]) + + # An empty list clears the assignment + response = self.client.patch(url, {'module_bay_types': []}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(module_bay.module_bay_types.count(), 0) + class DeviceBayTestCase(APIViewTestCases.APIViewTestCase): model = DeviceBay @@ -3961,6 +5202,326 @@ class PowerFeedTestCase(APIViewTestCases.APIViewTestCase): ] +class CoolingIntakeTemplateTestCase(APIViewTestCases.APIViewTestCase): + model = CoolingIntakeTemplate + brief_fields = ['description', 'display', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create( + manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' + ) + moduletype = ModuleType.objects.create( + manufacturer=manufacturer, model='Module Type 1' + ) + + cooling_intake_templates = ( + CoolingIntakeTemplate(device_type=devicetype, name='Cooling Port Template 1'), + CoolingIntakeTemplate(device_type=devicetype, name='Cooling Port Template 2'), + CoolingIntakeTemplate(device_type=devicetype, name='Cooling Port Template 3'), + ) + CoolingIntakeTemplate.objects.bulk_create(cooling_intake_templates) + + cls.create_data = [ + { + 'device_type': devicetype.pk, + 'name': 'Cooling Port Template 4', + 'type': CoolingConnectorTypeChoices.TYPE_UQD, + }, + { + 'device_type': devicetype.pk, + 'name': 'Cooling Port Template 5', + }, + { + 'module_type': moduletype.pk, + 'name': 'Cooling Port Template 6', + }, + { + 'module_type': moduletype.pk, + 'name': 'Cooling Port Template 7', + }, + ] + + +class CoolingOutflowTemplateTestCase(APIViewTestCases.APIViewTestCase): + model = CoolingOutflowTemplate + brief_fields = ['description', 'display', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } + user_permissions = ('dcim.view_devicetype', ) + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create( + manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' + ) + moduletype = ModuleType.objects.create( + manufacturer=manufacturer, model='Module Type 1' + ) + + cooling_intake_templates = ( + CoolingIntakeTemplate(device_type=devicetype, name='Cooling Port Template 1'), + CoolingIntakeTemplate(device_type=devicetype, name='Cooling Port Template 2'), + ) + CoolingIntakeTemplate.objects.bulk_create(cooling_intake_templates) + + cooling_outflow_templates = ( + CoolingOutflowTemplate(device_type=devicetype, name='Cooling Outlet Template 1'), + CoolingOutflowTemplate(device_type=devicetype, name='Cooling Outlet Template 2'), + CoolingOutflowTemplate(device_type=devicetype, name='Cooling Outlet Template 3'), + ) + CoolingOutflowTemplate.objects.bulk_create(cooling_outflow_templates) + + cls.create_data = [ + { + 'device_type': devicetype.pk, + 'name': 'Cooling Outlet Template 4', + 'type': CoolingConnectorTypeChoices.TYPE_UQD, + 'cooling_intake': cooling_intake_templates[0].pk, + }, + { + 'device_type': devicetype.pk, + 'name': 'Cooling Outlet Template 5', + 'cooling_intake': cooling_intake_templates[1].pk, + }, + { + 'device_type': devicetype.pk, + 'name': 'Cooling Outlet Template 6', + 'cooling_intake': None, + }, + { + 'module_type': moduletype.pk, + 'name': 'Cooling Outlet Template 7', + }, + { + 'module_type': moduletype.pk, + 'name': 'Cooling Outlet Template 8', + }, + ] + + +class CoolingIntakeTestCase(APIViewTestCases.APIViewTestCase): + model = CoolingIntake + brief_fields = ['description', 'device', 'display', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } + user_permissions = ('dcim.view_device', ) + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') + site = Site.objects.create(name='Site 1', slug='site-1') + role = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000') + device = Device.objects.create(device_type=devicetype, role=role, name='Device 1', site=site) + + cooling_outflow = CoolingOutflow.objects.create(device=device, name='Cooling Outlet 1') + + cooling_intakes = ( + CoolingIntake(device=device, name='Cooling Port 1'), + CoolingIntake(device=device, name='Cooling Port 2'), + CoolingIntake(device=device, name='Cooling Port 3'), + ) + CoolingIntake.objects.bulk_create(cooling_intakes) + + cls.create_data = [ + { + 'device': device.pk, + 'name': 'Cooling Port 4', + 'type': CoolingConnectorTypeChoices.TYPE_UQD, + 'cooling_outflow': cooling_outflow.pk, + }, + { + 'device': device.pk, + 'name': 'Cooling Port 5', + 'type': CoolingConnectorTypeChoices.TYPE_QDC, + }, + { + 'device': device.pk, + 'name': 'Cooling Port 6', + }, + ] + + +class CoolingOutflowTestCase(APIViewTestCases.APIViewTestCase): + model = CoolingOutflow + brief_fields = ['description', 'device', 'display', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } + user_permissions = ('dcim.view_device', ) + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') + site = Site.objects.create(name='Site 1', slug='site-1') + role = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000') + device = Device.objects.create(device_type=devicetype, role=role, name='Device 1', site=site) + + cooling_intakes = ( + CoolingIntake(device=device, name='Cooling Port 1'), + CoolingIntake(device=device, name='Cooling Port 2'), + ) + CoolingIntake.objects.bulk_create(cooling_intakes) + + cooling_outflows = ( + CoolingOutflow(device=device, name='Cooling Outlet 1'), + CoolingOutflow(device=device, name='Cooling Outlet 2'), + CoolingOutflow(device=device, name='Cooling Outlet 3'), + ) + CoolingOutflow.objects.bulk_create(cooling_outflows) + + cls.create_data = [ + { + 'device': device.pk, + 'name': 'Cooling Outlet 4', + 'type': CoolingConnectorTypeChoices.TYPE_UQD, + 'cooling_intake': cooling_intakes[0].pk, + }, + { + 'device': device.pk, + 'name': 'Cooling Outlet 5', + 'cooling_intake': cooling_intakes[1].pk, + }, + { + 'device': device.pk, + 'name': 'Cooling Outlet 6', + 'cooling_intake': None, + }, + ] + + +class CoolingSourceTestCase(APIViewTestCases.APIViewTestCase): + model = CoolingSource + brief_fields = ['coolingfeed_count', 'description', 'display', 'id', 'name', 'url'] + user_permissions = ('dcim.view_site', ) + + @classmethod + def setUpTestData(cls): + sites = ( + Site.objects.create(name='Site 1', slug='site-1'), + Site.objects.create(name='Site 2', slug='site-2'), + ) + + locations = ( + Location.objects.create(name='Location 1', slug='location-1', site=sites[0]), + Location.objects.create(name='Location 2', slug='location-2', site=sites[0]), + Location.objects.create(name='Location 3', slug='location-3', site=sites[0]), + Location.objects.create(name='Location 4', slug='location-3', site=sites[1]), + ) + + cooling_sources = ( + CoolingSource( + site=sites[0], location=locations[0], name='Cooling Source 1', + type=CoolingSourceTypeChoices.TYPE_CHILLER + ), + CoolingSource( + site=sites[0], location=locations[1], name='Cooling Source 2', + type=CoolingSourceTypeChoices.TYPE_COOLING_TOWER + ), + CoolingSource( + site=sites[0], location=locations[2], name='Cooling Source 3', + type=CoolingSourceTypeChoices.TYPE_DRY_COOLER + ), + ) + CoolingSource.objects.bulk_create(cooling_sources) + + cls.create_data = [ + { + 'name': 'Cooling Source 4', + 'site': sites[0].pk, + 'location': locations[0].pk, + 'type': CoolingSourceTypeChoices.TYPE_CHILLER, + }, + { + 'name': 'Cooling Source 5', + 'site': sites[0].pk, + 'location': locations[1].pk, + 'type': CoolingSourceTypeChoices.TYPE_CHILLER, + }, + { + 'name': 'Cooling Source 6', + 'site': sites[0].pk, + 'location': locations[2].pk, + 'type': CoolingSourceTypeChoices.TYPE_CHILLER, + }, + ] + + cls.bulk_update_data = { + 'site': sites[1].pk, + 'location': locations[3].pk + } + + +class CoolingFeedTestCase(APIViewTestCases.APIViewTestCase): + model = CoolingFeed + brief_fields = ['description', 'display', 'id', 'name', 'url'] + bulk_update_data = { + 'status': 'planned', + } + user_permissions = ('dcim.view_coolingsource', ) + + @classmethod + def setUpTestData(cls): + site = Site.objects.create(name='Site 1', slug='site-1') + location = Location.objects.create(site=site, name='Location 1', slug='location-1') + rackrole = RackRole.objects.create(name='Rack Role 1', slug='rack-role-1', color='ff0000') + + racks = ( + Rack(site=site, location=location, role=rackrole, name='Rack 1'), + Rack(site=site, location=location, role=rackrole, name='Rack 2'), + Rack(site=site, location=location, role=rackrole, name='Rack 3'), + Rack(site=site, location=location, role=rackrole, name='Rack 4'), + ) + Rack.objects.bulk_create(racks) + + cooling_sources = ( + CoolingSource( + site=site, location=location, name='Cooling Source 1', type=CoolingSourceTypeChoices.TYPE_CHILLER + ), + CoolingSource( + site=site, location=location, name='Cooling Source 2', type=CoolingSourceTypeChoices.TYPE_CHILLER + ), + ) + CoolingSource.objects.bulk_create(cooling_sources) + + cooling_feeds = ( + CoolingFeed(cooling_source=cooling_sources[0], rack=racks[0], name='Cooling Feed 1A'), + CoolingFeed(cooling_source=cooling_sources[1], rack=racks[0], name='Cooling Feed 1B'), + CoolingFeed(cooling_source=cooling_sources[0], rack=racks[1], name='Cooling Feed 2A'), + CoolingFeed(cooling_source=cooling_sources[1], rack=racks[1], name='Cooling Feed 2B'), + CoolingFeed(cooling_source=cooling_sources[0], rack=racks[2], name='Cooling Feed 3A'), + CoolingFeed(cooling_source=cooling_sources[1], rack=racks[2], name='Cooling Feed 3B'), + ) + CoolingFeed.objects.bulk_create(cooling_feeds) + + cls.create_data = [ + { + 'name': 'Cooling Feed 4A', + 'cooling_source': cooling_sources[0].pk, + 'rack': racks[3].pk, + }, + { + 'name': 'Cooling Feed 4B', + 'cooling_source': cooling_sources[1].pk, + 'rack': racks[3].pk, + }, + { + 'name': 'Cooling Feed 4C', + 'cooling_source': cooling_sources[0].pk, + 'rack': racks[3].pk, + }, + ] + + class VirtualDeviceContextTestCase(APIViewTestCases.APIViewTestCase): model = VirtualDeviceContext brief_fields = ['description', 'device', 'display', 'id', 'identifier', 'name', 'url'] @@ -4125,3 +5686,25 @@ class MACAddressTestCase(APIViewTestCases.APIViewTestCase): 'mac_address': '00:00:00:00:00:06', }, ] + + def test_is_primary_field(self): + """ + The read-only is_primary field should reflect whether the MAC address is the primary on its interface. + """ + self.add_permissions('dcim.view_macaddress') + + primary_mac = MACAddress.objects.get(mac_address='00:00:00:00:00:01') + non_primary_mac = MACAddress.objects.get(mac_address='00:00:00:00:00:02') + + # Designate one MAC address as the primary on its interface + interface = primary_mac.assigned_object + interface.primary_mac_address = primary_mac + interface.save() + + url = reverse('dcim-api:macaddress-detail', kwargs={'pk': primary_mac.pk}) + response = self.client.get(url, **self.header) + self.assertTrue(response.data['is_primary']) + + url = reverse('dcim-api:macaddress-detail', kwargs={'pk': non_primary_mac.pk}) + response = self.client.get(url, **self.header) + self.assertFalse(response.data['is_primary']) diff --git a/netbox/dcim/tests/test_channelization.py b/netbox/dcim/tests/test_channelization.py new file mode 100644 index 000000000..86a1d26dc --- /dev/null +++ b/netbox/dcim/tests/test_channelization.py @@ -0,0 +1,1293 @@ +import json +from unittest import mock + +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.db import connection, router +from django.test import Client, TestCase, TransactionTestCase +from django.test.utils import CaptureQueriesContext +from django.urls import reverse +from rest_framework.test import APIClient + +from core.choices import ObjectChangeActionChoices +from core.models import ObjectChange +from dcim.choices import CableProfileChoices, InterfaceTypeChoices +from dcim.filtersets import InterfaceFilterSet +from dcim.models import ( + Cable, + CablePath, + Device, + DeviceRole, + DeviceType, + Interface, + InterfaceTemplate, + Manufacturer, + Site, +) +from dcim.svg import CableTraceSVG +from dcim.svg.cables import Connector +from dcim.tests.utils import BaseCablePathTestCase +from users.constants import TOKEN_PREFIX +from users.models import Token, User +from utilities.ordering import naturalize_interface +from utilities.testing import TestCase as ViewTestCase + + +class ChannelizedCablePathTestCase(BaseCablePathTestCase): + """ + Test cable path tracing for channelized interfaces. A single physical cable terminates to a channelized (parent) + interface, and each of the parent's channel subinterfaces traces an independent path from the connector position + identified by its channel_id. + """ + + def _create_channelized_interface(self, name, channels, device=None): + """Create a channelized parent interface and its channel subinterfaces.""" + device = device or self.device + parent = Interface.objects.create( + device=device, name=name, type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=channels + ) + children = [ + Interface.objects.create( + device=device, + name=f'{name}:{i}', + type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=parent, + channel_id=i, + ) + for i in range(1, channels + 1) + ] + return parent, children + + def test_101_channelized_breakout_to_discrete_interfaces(self): + """ + A 4-channel parent broken out to four discrete far-end interfaces via a 1C4P:4C1P breakout cable. Each channel + subinterface traces to its corresponding far-end interface (and vice versa); the parent itself has no path. + """ + parent, channels = self._create_channelized_interface('et0', 4) + far = [ + Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS) + for i in range(4) + ] + + cable = Cable( + profile=CableProfileChoices.BREAKOUT_1C4P_4C1P, + a_terminations=[parent], + b_terminations=far, + ) + cable.clean() + cable.save() + + # One forward and one reverse path per channel; the parent originates no path + self.assertEqual(CablePath.objects.count(), 8) + parent.refresh_from_db() + self.assertPathIsNotSet(parent) + + for i, (channel, far_iface) in enumerate(zip(channels, far), start=1): + channel.refresh_from_db() + far_iface.refresh_from_db() + + # The parent's cable is mirrored onto the channel, restricted to its single connector position + self.assertEqual(channel.cable_id, cable.pk) + self.assertEqual(channel.cable_connector, 1) + self.assertEqual(channel.cable_positions, [i]) + + forward = self.assertPathExists((channel, cable, far_iface), is_complete=True, is_active=True) + reverse = self.assertPathExists((far_iface, cable, channel), is_complete=True, is_active=True) + self.assertPathIsSet(channel, forward) + self.assertPathIsSet(far_iface, reverse) + + # The trace SVG must render from both a channel subinterface and a discrete far-end interface + CableTraceSVG(channels[0]).render() + CableTraceSVG(far[0]).render() + + def test_102_channelized_to_channelized(self): + """ + Two channelized interfaces connected by a single 1C4P cable (both ends channelized on one connector). Each + near-end channel traces to the far-end channel bound to the same position. + """ + near_parent, near_channels = self._create_channelized_interface('et0', 4) + far_device = Device.objects.create( + site=self.site, device_type=self.device.device_type, role=self.device.role, name='Device 2' + ) + far_parent, far_channels = self._create_channelized_interface('et0', 4, device=far_device) + + cable = Cable( + profile=CableProfileChoices.SINGLE_1C4P, + a_terminations=[near_parent], + b_terminations=[far_parent], + ) + cable.clean() + cable.save() + + self.assertEqual(CablePath.objects.count(), 8) + for near, far in zip(near_channels, far_channels): + near.refresh_from_db() + far.refresh_from_db() + self.assertPathExists((near, cable, far), is_complete=True, is_active=True) + self.assertPathExists((far, cable, near), is_complete=True, is_active=True) + + # The trace SVG for a channel subinterface must render, drawing the cable between the two channels. The cable + # terminates on the parent interfaces, so the connector is matched to the channels via their parents. + svg = CableTraceSVG(near_channels[0]) + svg.render() + self.assertTrue( + any(isinstance(c, Connector) for c in svg.connectors), + msg="Trace SVG did not render a cable connector for the channelized path" + ) + + def test_103_add_channel_after_cabling(self): + """ + On an already-cabled parent, deleting a channel subinterface tears down its path, and adding a channel + subinterface (re-adding one on the freed position) builds a fresh path for it in both directions. + """ + parent, channels = self._create_channelized_interface('et0', 4) + far = [ + Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS) + for i in range(4) + ] + cable = Cable( + profile=CableProfileChoices.BREAKOUT_1C4P_4C1P, + a_terminations=[parent], + b_terminations=far, + ) + cable.clean() + cable.save() + + # Removing the fourth channel tears down its complete path in both directions + channels[3].delete() + self.assertPathDoesNotExist((channels[3], cable, far[3])) + self.assertPathDoesNotExist((far[3], cable, channels[3])) + + # Re-adding a channel on position 4 restores the complete path in both directions + new_channel = Interface.objects.create( + device=self.device, name='et0:4', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=parent, channel_id=4 + ) + new_channel.refresh_from_db() + self.assertEqual(new_channel.cable_positions, [4]) + self.assertPathExists((new_channel, cable, far[3]), is_complete=True, is_active=True) + self.assertPathExists((far[3], cable, new_channel), is_complete=True, is_active=True) + + def test_104_change_channel_id(self): + """ + Changing a channel's channel_id re-binds it to a different connector position, in both directions. + """ + parent, channels = self._create_channelized_interface('et0', 4) + far = [ + Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS) + for i in range(4) + ] + # Delete channels 3 and 4 so their positions are free to reassign to + channels[2].delete() + channels[3].delete() + cable = Cable( + profile=CableProfileChoices.BREAKOUT_1C4P_4C1P, + a_terminations=[parent], + b_terminations=far, + ) + cable.clean() + cable.save() + + # Channel 1 initially traces to far[0] + self.assertPathExists((channels[0], cable, far[0]), is_complete=True, is_active=True) + + # Move channel 1 to position 3 + channels[0].channel_id = 3 + channels[0].save() + channels[0].refresh_from_db() + + self.assertEqual(channels[0].cable_positions, [3]) + self.assertPathDoesNotExist((channels[0], cable, far[0])) + self.assertPathExists((channels[0], cable, far[2]), is_complete=True, is_active=True) + self.assertPathExists((far[2], cable, channels[0]), is_complete=True, is_active=True) + + def test_105_incomplete_channel(self): + """ + A channel whose position has no far-end termination yields an incomplete path (rather than an error). + """ + parent, channels = self._create_channelized_interface('et0', 4) + # Only two far-end interfaces exist, on connectors 1 and 2 + far = [ + Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS) + for i in range(2) + ] + cable = Cable( + profile=CableProfileChoices.BREAKOUT_1C4P_4C1P, + a_terminations=[parent], + b_terminations=far, + ) + cable.clean() + cable.save() + + # Channels 1 & 2 are complete; channels 3 & 4 have no far-end termination and trace an incomplete path + channels[0].refresh_from_db() + channels[2].refresh_from_db() + self.assertPathExists((channels[0], cable, far[0]), is_complete=True) + self.assertIsNotNone(channels[2]._path_id) + self.assertFalse(channels[2].path.is_complete) + + def test_106_cable_removal_teardown(self): + """ + Removing the cable from a channelized parent tears down every channel's path and clears the mirrored cable + attributes from the channels. + """ + parent, channels = self._create_channelized_interface('et0', 4) + far = [ + Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS) + for i in range(4) + ] + cable = Cable( + profile=CableProfileChoices.BREAKOUT_1C4P_4C1P, + a_terminations=[parent], + b_terminations=far, + ) + cable.clean() + cable.save() + self.assertEqual(CablePath.objects.count(), 8) + + cable.delete() + + self.assertEqual(CablePath.objects.count(), 0) + for channel in channels: + channel.refresh_from_db() + self.assertIsNone(channel.cable_id) + self.assertIsNone(channel.cable_connector) + self.assertIsNone(channel.cable_positions) + self.assertPathIsNotSet(channel) + + def test_107_direct_cabling_of_channel_rejected(self): + """ + A cable cannot be terminated directly to a channel subinterface. + """ + parent, channels = self._create_channelized_interface('et0', 4) + far = Interface.objects.create( + device=self.device, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS + ) + cable = Cable(a_terminations=[channels[0]], b_terminations=[far]) + with self.assertRaises(ValidationError): + cable.clean() + + def test_108_unprofiled_cable_not_propagated(self): + """ + An unprofiled cable carries no per-channel positions, so its attributes are not mirrored onto the parent's + channel subinterfaces. + """ + parent, channels = self._create_channelized_interface('et0', 4) + far = Interface.objects.create( + device=self.device, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS + ) + cable = Cable(a_terminations=[parent], b_terminations=[far]) + cable.clean() + cable.save() + + # The parent itself is cabled, but no cable attributes are mirrored onto the channels + parent.refresh_from_db() + self.assertEqual(parent.cable_id, cable.pk) + for channel in channels: + channel.refresh_from_db() + self.assertIsNone(channel.cable_id) + self.assertIsNone(channel.cable_positions) + + def test_109_change_channel_count_after_cabling(self): + """ + Increasing the channel count on an already-cabled parent re-propagates the cable to its existing channel + subinterfaces and rebuilds their paths (the Cable itself is unchanged, so only the post_save signal fires). + """ + parent, channels = self._create_channelized_interface('et0', 4) + far = [ + Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS) + for i in range(4) + ] + cable = Cable( + profile=CableProfileChoices.BREAKOUT_1C4P_4C1P, + a_terminations=[parent], + b_terminations=far, + ) + cable.clean() + cable.save() + self.assertEqual(CablePath.objects.count(), 8) + + # Increase the channel count; the existing channels' paths must survive + parent.refresh_from_db() + parent.channels = 8 + parent.save() + + self.assertEqual(CablePath.objects.count(), 8) + for i, (channel, far_iface) in enumerate(zip(channels, far), start=1): + channel.refresh_from_db() + self.assertEqual(channel.cable_positions, [i]) + self.assertPathExists((channel, cable, far_iface), is_complete=True, is_active=True) + + def test_110_move_channel_to_uncabled_parent(self): + """ + Moving a channel subinterface from a cabled parent to a channelized-but-uncabled parent tears down the + channel's mirrored cable attributes and its (now orphaned) path. + """ + parent, channels = self._create_channelized_interface('et0', 4) + far = [ + Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS) + for i in range(4) + ] + cable = Cable( + profile=CableProfileChoices.BREAKOUT_1C4P_4C1P, + a_terminations=[parent], + b_terminations=far, + ) + cable.clean() + cable.save() + + # A second channelized parent with no cable + uncabled_parent = Interface.objects.create( + device=self.device, name='et1', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4 + ) + + # Move the first channel to the uncabled parent; its mirrored cable & path must be torn down + channel = channels[0] + channel.refresh_from_db() + self.assertEqual(channel.cable_id, cable.pk) + channel.parent = uncabled_parent + channel.save() + + channel.refresh_from_db() + self.assertIsNone(channel.cable_id) + self.assertIsNone(channel.cable_connector) + self.assertIsNone(channel.cable_positions) + self.assertPathIsNotSet(channel) + self.assertPathDoesNotExist((channel, cable, far[0])) + self.assertPathDoesNotExist((far[0], cable, channel)) + + def _rename_cabled_channelized_pair(self, device_suffix, channel_count): + """ + Build a cabled pair of channelized interfaces with the given channel count, rename the near parent, and + return (query_count, near_channels, far_channels, cable) for the caller to assert against. + """ + far_device = Device.objects.create( + site=self.site, device_type=self.device.device_type, role=self.device.role, + name=f'Device {device_suffix}' + ) + near_parent, near_channels = self._create_channelized_interface(f'et{device_suffix}', channel_count) + far_parent, far_channels = self._create_channelized_interface( + f'et{device_suffix}', channel_count, device=far_device + ) + profile = {2: CableProfileChoices.SINGLE_1C2P, 8: CableProfileChoices.SINGLE_1C8P}[channel_count] + cable = Cable(profile=profile, a_terminations=[near_parent], b_terminations=[far_parent]) + cable.clean() + cable.save() + + near_parent.refresh_from_db() + with CaptureQueriesContext(connection) as ctx: + near_parent.name = f'ex{device_suffix}' + with self.captureOnCommitCallbacks(execute=True): + near_parent.save() + + return len(ctx.captured_queries), near_channels, far_channels, cable + + def test_111_rename_cabled_parent_preserves_cable_paths_without_quadratic_cost(self): + """ + Renaming a cabled channelized parent must cascade the children's names without disturbing their cable + paths, and without re-deriving cable state per child (which would make the rename quadratic in the + channel count). Pinned by comparing query cost at 2 vs. 8 channels: linear per-child work scales with + the 4x channel growth; a quadratic regression would blow well past it. + """ + queries_2ch, near_channels_2ch, far_channels_2ch, cable_2ch = self._rename_cabled_channelized_pair('A', 2) + queries_8ch, near_channels_8ch, far_channels_8ch, cable_8ch = self._rename_cabled_channelized_pair('B', 8) + + self.assertLess( + queries_8ch, queries_2ch * 4, + "Renaming an 8-channel cabled parent cost disproportionately more than a 2-channel one; check " + "whether update_channelized_cable_paths is re-running a full cable/path rebuild per renamed child." + ) + + # Both the 2- and 8-channel cascades must have actually renamed and preserved paths correctly; checking + # only the query count above would still pass if the larger (8-channel) cascade silently did neither. + for prefix, near_channels, far_channels, cable in ( + ('exA:', near_channels_2ch, far_channels_2ch, cable_2ch), + ('exB:', near_channels_8ch, far_channels_8ch, cable_8ch), + ): + for near, far in zip(near_channels, far_channels): + near.refresh_from_db() + self.assertTrue(near.name.startswith(prefix)) + self.assertEqual(near.cable_id, cable.pk) + self.assertPathExists((near, cable, far), is_complete=True, is_active=True) + self.assertPathExists((far, cable, near), is_complete=True, is_active=True) + + def test_112_full_resave_of_unchanged_channel_child_skips_propagation(self): + """ + A full re-save of an already-channelized child with neither channel_id nor parent actually changed must + not re-propagate cable state or rebuild the parent's paths; previously only update_fields-excluded + partial saves were guarded, so a full save of an unrelated field still passed through. + """ + parent, channels = self._create_channelized_interface('et0', 4) + far = [ + Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS) + for i in range(4) + ] + cable = Cable(profile=CableProfileChoices.BREAKOUT_1C4P_4C1P, a_terminations=[parent], b_terminations=far) + cable.clean() + cable.save() + + channel = channels[0] + channel.refresh_from_db() + channel.description = 'updated' + with ( + mock.patch.object(Interface, 'propagate_channel_cables') as mock_propagate, + mock.patch('dcim.signals.rebuild_cable_paths') as mock_rebuild, + ): + channel.save() + + mock_propagate.assert_not_called() + mock_rebuild.assert_not_called() + + def test_113_detach_channel_clears_stale_cable_attributes(self): + """ + Fully detaching a channel subinterface (clearing both parent and channel_id) must clear its mirrored + cable attributes too -- once detached, it drops out of the old parent's propagation queryset and would + otherwise retain a stale cable_id indefinitely. + """ + parent, channels = self._create_channelized_interface('et0', 4) + far = [ + Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS) + for i in range(4) + ] + cable = Cable(profile=CableProfileChoices.BREAKOUT_1C4P_4C1P, a_terminations=[parent], b_terminations=far) + cable.clean() + cable.save() + + channel = channels[0] + channel.refresh_from_db() + self.assertEqual(channel.cable_id, cable.pk) + + channel.parent = None + channel.channel_id = None + channel.type = InterfaceTypeChoices.TYPE_10GE_SFP_PLUS + channel.full_clean() + channel.save() + + channel.refresh_from_db() + self.assertIsNone(channel.cable_id) + self.assertIsNone(channel.cable_connector) + self.assertIsNone(channel.cable_positions) + self.assertPathIsNotSet(channel) + + +class ChannelizedInterfaceTestCase(TestCase): + """ + Test validation, properties, renaming, and REST/GraphQL filtering of channelized Interfaces and their channel + subinterfaces. Cable-path and bulk-view coverage remain in their own specialized TestCase classes below; + commit-dependent cascade side effects remain in the separate ChannelizedInterfaceRenameSideEffectsTestCase + (a TransactionTestCase). + """ + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Generic', slug='generic') + device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device') + role = DeviceRole.objects.create(name='Device Role', slug='device-role') + site = Site.objects.create(name='Site', slug='site') + cls.device = Device.objects.create(site=site, device_type=device_type, role=role, name='Device 1') + cls.parent = Interface.objects.create( + device=cls.device, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4 + ) + + # A second, isolated device for the kind=physical filter tests further below, so their pre-built channel + # subinterface doesn't collide with the many ad hoc channel_id=1 children the tests above create against + # cls.parent. + cls.filter_device = Device.objects.create(site=site, device_type=device_type, role=role, name='Device 2') + cls.filter_parent = Interface.objects.create( + device=cls.filter_device, name='ft0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=1 + ) + cls.filter_channel = Interface.objects.create( + device=cls.filter_device, name='ft0:1', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS, + parent=cls.filter_parent, channel_id=1 + ) + cls.filter_plain = Interface.objects.create( + device=cls.filter_device, name='fx0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS + ) + + # -- validation -------------------------------------------------------------------------------------------- + + def test_valid_channel_subinterface(self): + interface = Interface( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1 + ) + interface.full_clean() # Should not raise + + def test_channel_type_requires_channel_id(self): + interface = Interface( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent + ) + with self.assertRaises(ValidationError): + interface.full_clean() + + def test_channel_id_allowed_on_specific_physical_type(self): + # A channel subinterface may keep its own specific physical type (e.g. to record the actual transceiver + # in use) instead of the generic "channel" type. + interface = Interface( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS, + parent=self.parent, channel_id=1 + ) + interface.full_clean() # Should not raise + + def test_channel_id_rejected_on_virtual_type(self): + interface = Interface( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_VIRTUAL, + parent=self.parent, channel_id=1 + ) + with self.assertRaises(ValidationError): + interface.full_clean() + + def test_physical_type_parent_requires_channel_id(self): + # A physical interface type may not simply be assigned a parent without also being bound to a channel + interface = Interface( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS, parent=self.parent + ) + with self.assertRaises(ValidationError): + interface.full_clean() + + def test_channel_id_rejected_on_lag_type(self): + interface = Interface( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_LAG, parent=self.parent, channel_id=1 + ) + with self.assertRaises(ValidationError): + interface.full_clean() + + def test_channel_subinterface_with_physical_type_is_not_wired(self): + # A channel subinterface derives its cable from its parent and cannot be cabled directly, regardless of + # whether it uses the generic "channel" type or its own specific physical type. + interface = Interface.objects.create( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS, + parent=self.parent, channel_id=1 + ) + self.assertFalse(interface.is_wired) + + def test_channel_subinterface_with_physical_type_is_channel(self): + # is_channel is identified by channel_id, not by type, so it must agree with is_wired for a channel + # subinterface that keeps its own specific physical type. + interface = Interface.objects.create( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS, + parent=self.parent, channel_id=1 + ) + self.assertTrue(interface.is_channel) + + def test_generic_channel_type_is_channel(self): + interface = Interface.objects.create( + device=self.device, name='et0:2', type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=self.parent, channel_id=2 + ) + self.assertTrue(interface.is_channel) + + def test_non_channel_interface_is_not_channel(self): + interface = Interface.objects.create( + device=self.device, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS + ) + self.assertFalse(interface.is_channel) + + def test_channel_requires_parent(self): + interface = Interface( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, channel_id=1 + ) + with self.assertRaises(ValidationError): + interface.full_clean() + + def test_channel_requires_channelized_parent(self): + plain_parent = Interface.objects.create( + device=self.device, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS + ) + interface = Interface( + device=self.device, name='xe0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=plain_parent, channel_id=1 + ) + with self.assertRaises(ValidationError): + interface.full_clean() + + def test_channel_id_within_parent_range(self): + interface = Interface( + device=self.device, name='et0:5', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=5 + ) + with self.assertRaises(ValidationError): + interface.full_clean() + + def test_channels_and_channel_id_mutually_exclusive(self): + interface = Interface( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=self.parent, channel_id=1, channels=4 + ) + with self.assertRaises(ValidationError): + interface.full_clean() + + def test_channels_not_allowed_on_virtual_type(self): + interface = Interface( + device=self.device, name='vlan10', type=InterfaceTypeChoices.TYPE_VIRTUAL, channels=4 + ) + with self.assertRaises(ValidationError): + interface.full_clean() + + def test_reduce_channels_below_bound_child_rejected(self): + # Bind a channel to the highest channel of the parent, then attempt to reduce the parent's channel count + Interface.objects.create( + device=self.device, name='et0:4', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=4 + ) + self.parent.channels = 2 + with self.assertRaises(ValidationError): + self.parent.full_clean() + + def test_clear_channels_with_bound_child_rejected(self): + # De-channelizing a parent entirely must be rejected while any channel subinterface is still bound to it + Interface.objects.create( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1 + ) + self.parent.channels = None + with self.assertRaises(ValidationError): + self.parent.full_clean() + + def test_clear_channels_without_bound_child_allowed(self): + # De-channelizing is permitted once no channel subinterfaces remain bound to the parent + self.parent.channels = None + self.parent.full_clean() # Should not raise + + def test_parent_channel_id_must_be_unique(self): + Interface.objects.create( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1 + ) + duplicate = Interface( + device=self.device, name='et0:1b', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1 + ) + with self.assertRaises(ValidationError): + duplicate.full_clean() + + def test_channel_id_rejected_on_interface_with_existing_cable_termination(self): + # A channel subinterface's cable state is mirrored from its parent; an interface that already carries its + # own direct cable connection cannot also be converted into one. + interface = Interface.objects.create( + device=self.device, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS + ) + far = Interface.objects.create( + device=self.device, name='xe1', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS + ) + cable = Cable(a_terminations=[interface], b_terminations=[far]) + cable.clean() + cable.save() + + interface.refresh_from_db() + interface.parent = self.parent + interface.channel_id = 1 + with self.assertRaises(ValidationError): + interface.full_clean() + + # -- renaming ---------------------------------------------------------------------------------------------- + # Renaming a channelized parent interface updates the names of any channel subinterfaces which follow the + # ":" convention. + + def test_rename_updates_conforming_children(self): + child = Interface.objects.create( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1 + ) + + self.parent.name = 'et1' + with self.captureOnCommitCallbacks(execute=True): + self.parent.save() + + child.refresh_from_db() + self.assertEqual(child.name, 'et1:1') + + def test_rename_cascade_uses_save_state_db_not_router(self): + # The deferred callback and child query/save must reuse self._state.db (the DB actually used by + # save()), not re-invoke router.db_for_write() -- which could differ from an explicit save(using=...). + # Django's own base Model.save() legitimately consults the router once per plain save() call (when no + # explicit using= is given); the pre-fix mixin code consulted it twice more for the same instance during + # the cascade. Spy on calls for the Interface model specifically to confirm only that one call remains. + child = Interface.objects.create( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1 + ) + + self.parent.name = 'et1' + real_db_for_write = router.db_for_write + calls = [] + + def spy(model, **hints): + if model is Interface: + calls.append(model) + return real_db_for_write(model, **hints) + + with mock.patch('django.db.router.db_for_write', side_effect=spy): + with self.captureOnCommitCallbacks(execute=True): + self.parent.save() + + child.refresh_from_db() + self.assertEqual(child.name, 'et1:1') + self.assertEqual( + len(calls), 1, + "router.db_for_write(Interface) was consulted more than once; the rename cascade should reuse " + "self._state.db instead of re-invoking the router." + ) + + def test_rename_leaves_nonconforming_children_untouched(self): + child = Interface.objects.create( + device=self.device, name='et0-custom', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, + channel_id=1 + ) + + self.parent.name = 'et1' + with self.captureOnCommitCallbacks(execute=True): + self.parent.save() + + child.refresh_from_db() + self.assertEqual(child.name, 'et0-custom') + + def test_rename_skips_child_on_collision(self): + colliding_child = Interface.objects.create( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1 + ) + Interface.objects.create(device=self.device, name='et1:1', type=InterfaceTypeChoices.TYPE_VIRTUAL) + + self.parent.name = 'et1' + with self.captureOnCommitCallbacks(execute=True): + self.parent.save() + + colliding_child.refresh_from_db() + self.assertEqual(colliding_child.name, 'et0:1') + + def test_rename_collision_on_one_child_does_not_block_others(self): + # colliding_child conforms to the naming convention, so it reaches save() and genuinely hits + # IntegrityError; a collision there must not block the other, non-colliding child's rename. + colliding_child = Interface.objects.create( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1 + ) + clear_child = Interface.objects.create( + device=self.device, name='et0:2', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=2 + ) + Interface.objects.create(device=self.device, name='et1:1', type=InterfaceTypeChoices.TYPE_VIRTUAL) + + self.parent.name = 'et1' + with self.captureOnCommitCallbacks(execute=True): + self.parent.save() + + colliding_child.refresh_from_db() + clear_child.refresh_from_db() + self.assertEqual(colliding_child.name, 'et0:1') + self.assertEqual(clear_child.name, 'et1:2') + + def test_rename_cascade_is_deferred_until_transaction_commits(self): + # A sibling object saved later in the same transaction (e.g. by a bulk view) must not be able to + # silently undo the cascade by writing back a stale in-memory copy of the child's name. + child = Interface.objects.create( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1 + ) + + with self.captureOnCommitCallbacks(execute=False) as callbacks: + self.parent.name = 'et1' + self.parent.save() + + # Deferred until "commit" (running the captured callbacks below): not yet propagated. + child.refresh_from_db() + self.assertEqual(child.name, 'et0:1') + + # Simulate a sibling's own save() in the same batch, re-asserting the child's stale name — exactly + # what BulkRenameView does when the same child is also selected in a bulk rename. + stale_copy = Interface.objects.get(pk=child.pk) + stale_copy.save() + + # The deferred cascade is the last write once the transaction commits: still renames the child. + for callback in callbacks: + callback() + child.refresh_from_db() + self.assertEqual(child.name, 'et1:1') + + def test_rename_of_non_channelized_interface_is_a_no_op(self): + plain = Interface.objects.create( + device=self.device, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS + ) + plain.name = 'xe1' + plain.save() # Should not raise despite having no channel subinterfaces to check + + def test_rename_then_channelize_then_rename_again(self): + # Renaming while channels is unset, then channelizing, then renaming again must correctly cascade the + # second rename to any child created in between. + interface = Interface.objects.create( + device=self.device, name='zz0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS + ) + interface.name = 'zz1' + interface.save() # Not yet channelized: no cascade, but _original_name must become 'zz1' + + interface.channels = 4 + interface.save() + child = Interface.objects.create( + device=self.device, name='zz1:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=interface, channel_id=1 + ) + + interface.name = 'zz2' + with self.captureOnCommitCallbacks(execute=True): + interface.save() + + child.refresh_from_db() + self.assertEqual(child.name, 'zz2:1') + + def test_original_name_is_set_for_an_instance_built_without_a_name_kwarg(self): + # An instance constructed without passing name= (so __init__ caches _original_name as None) must still + # cascade correctly once a name and channels are assigned and it's saved for the first time. + interface = Interface(device=self.device, type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS) + interface.name = 'zz0' + interface.channels = 4 + interface.save() + child = Interface.objects.create( + device=self.device, name='zz0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=interface, channel_id=1 + ) + + interface.name = 'zz1' + with self.captureOnCommitCallbacks(execute=True): + interface.save() + + child.refresh_from_db() + self.assertEqual(child.name, 'zz1:1') + + def test_save_with_update_fields_excluding_name_does_not_cascade(self): + # A save() that explicitly excludes 'name' from update_fields does not persist the in-memory name change, + # so it must not cascade a rename to children, nor treat that unpersisted name as the new baseline. + child = Interface.objects.create( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1 + ) + + self.parent.name = 'et1' + with self.captureOnCommitCallbacks(execute=True): + self.parent.save(update_fields=['description']) + + self.parent.refresh_from_db() + child.refresh_from_db() + self.assertEqual(self.parent.name, 'et0') # Not persisted + self.assertEqual(child.name, 'et0:1') # Not cascaded + + def test_generator_update_fields_cascades_rename(self): + # A one-shot iterable naming 'name' must still persist the rename and cascade it. + child = Interface.objects.create( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1 + ) + + self.parent.name = 'et1' + with self.captureOnCommitCallbacks(execute=True): + self.parent.save(update_fields=(field for field in ('name',))) + + self.parent.refresh_from_db() + child.refresh_from_db() + self.assertEqual(self.parent.name, 'et1') + self.assertEqual(child.name, 'et1:1') + + def test_update_fields_excluding_name_does_not_desync_later_full_rename(self): + # A later full save() must still correctly cascade, proving the earlier partial save didn't refresh + # _original_name to its unpersisted in-memory value. + child = Interface.objects.create( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1 + ) + + self.parent.name = 'et1' + self.parent.save(update_fields=['description']) # Not persisted; DB name is still 'et0' + + with self.captureOnCommitCallbacks(execute=True): + self.parent.save() # Full save: persists 'et1', cascading from the true prior (DB) name 'et0' + + child.refresh_from_db() + self.assertEqual(child.name, 'et1:1') + + # -- kind=physical filtering --------------------------------------------------------------------------------- + # A channel subinterface is excluded from kind=physical (REST) / kind: PHYSICAL (GraphQL), even when it keeps + # its own specific physical type rather than the generic "channel" type -- matching Interface.is_wired, since + # it derives its cable from its channelized parent and cannot be cabled directly. + + def test_rest_kind_physical_excludes_channel_subinterface(self): + filterset = InterfaceFilterSet({'kind': 'physical'}, Interface.objects.all()) + results = set(filterset.qs.values_list('pk', flat=True)) + self.assertIn(self.filter_parent.pk, results) + self.assertIn(self.filter_plain.pk, results) + self.assertNotIn(self.filter_channel.pk, results) + + def test_graphql_kind_physical_excludes_channel_subinterface(self): + user = User.objects.create_user(username='testuser', is_superuser=True) + client = Client() + client.force_login(user) + + query = '{ interface_list(filters: {kind: KIND_PHYSICAL}) { id } }' + response = client.post( + reverse('graphql'), data=json.dumps({'query': query}), content_type='application/json' + ) + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + self.assertNotIn('errors', data) + result_ids = {int(r['id']) for r in data['data']['interface_list']} + self.assertIn(self.filter_parent.pk, result_ids) + self.assertIn(self.filter_plain.pk, result_ids) + self.assertNotIn(self.filter_channel.pk, result_ids) + + +class ChannelizedInterfaceRenameSideEffectsTestCase(TransactionTestCase): + """ + Test that a cascaded channel subinterface rename behaves as a full save() (updating _name and last_updated, + and recording an ObjectChange), not merely as a raw name update. Uses TransactionTestCase, not TestCase, so + the request's transaction really commits and on_commit() fires inline as in production — under TestCase the + whole test runs inside one uncommitted transaction, and captureOnCommitCallbacks() would only fire the + deferred rename after the request (and its changelog's current_request context) has already torn down. + """ + + def setUp(self): + self.user = User.objects.create_user(username='testuser', is_superuser=True) + self.token = Token.objects.create(user=self.user) + self.header = {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{self.token.key}.{self.token.token}'} + self.client = APIClient() + + manufacturer = Manufacturer.objects.create(name='Generic', slug='generic') + device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device') + role = DeviceRole.objects.create(name='Device Role', slug='device-role') + site = Site.objects.create(name='Site', slug='site') + self.device = Device.objects.create(site=site, device_type=device_type, role=role, name='Device 1') + self.parent = Interface.objects.create( + device=self.device, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4 + ) + self.child = Interface.objects.create( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, + channel_id=1 + ) + + def _rename_parent(self): + url = reverse('dcim-api:interface-detail', kwargs={'pk': self.parent.pk}) + response = self.client.patch(url, {'name': 'et1'}, format='json', **self.header) + self.assertEqual(response.status_code, 200, response.data) + + def test_rename_updates_child_name_ordering_field(self): + self._rename_parent() + + self.child.refresh_from_db() + self.assertEqual(self.child.name, 'et1:1') + self.assertEqual(self.child._name, naturalize_interface('et1:1', max_length=100)) + + def test_rename_bumps_child_last_updated(self): + original_last_updated = self.child.last_updated + + self._rename_parent() + + self.child.refresh_from_db() + self.assertGreater(self.child.last_updated, original_last_updated) + + def test_rename_records_child_changelog_entry(self): + self._rename_parent() + + objectchange = ObjectChange.objects.filter( + action=ObjectChangeActionChoices.ACTION_UPDATE, + changed_object_type=ContentType.objects.get_for_model(Interface), + changed_object_id=self.child.pk, + ).first() + self.assertIsNotNone(objectchange, "No ObjectChange was recorded for the cascaded child rename") + self.assertEqual(objectchange.prechange_data['name'], 'et0:1') + self.assertEqual(objectchange.postchange_data['name'], 'et1:1') + + +class ChannelizedInterfaceTemplateTestCase(TestCase): + """ + Test validation, instantiation-time replication, and renaming of channelized InterfaceTemplates and their + channel subinterface templates. + """ + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Generic', slug='generic') + cls.device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device', slug='test-device') + cls.role = DeviceRole.objects.create(name='Device Role', slug='device-role') + cls.site = Site.objects.create(name='Site', slug='site') + cls.parent = InterfaceTemplate.objects.create( + device_type=cls.device_type, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4 + ) + + # A second, isolated device type with its own pre-built channel subinterface templates, for the + # instantiation-replication test below -- so its four pre-existing channel_id 1-4 children don't collide + # with the many ad hoc children the validation/rename tests create against cls.parent. + cls.replication_device_type = DeviceType.objects.create( + manufacturer=manufacturer, model='Replication Device', slug='replication-device' + ) + replication_parent = InterfaceTemplate.objects.create( + device_type=cls.replication_device_type, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, + channels=4 + ) + for i in range(1, 5): + InterfaceTemplate.objects.create( + device_type=cls.replication_device_type, + name=f'et0:{i}', + type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=replication_parent, + channel_id=i, + ) + + # -- instantiation-time replication ------------------------------------------------------------------------- + + def test_channelization_replicated_on_instantiation(self): + device = Device.objects.create( + site=self.site, device_type=self.replication_device_type, role=self.role, name='Device 1' + ) + + # The channelized parent carries its channel count + parent = device.interfaces.get(name='et0') + self.assertEqual(parent.channels, 4) + self.assertIsNone(parent.channel_id) + + # Each channel subinterface carries its channel ID and is bound to the instantiated parent interface + for i in range(1, 5): + channel = device.interfaces.get(name=f'et0:{i}') + self.assertEqual(channel.channel_id, i) + self.assertIsNone(channel.channels) + self.assertEqual(channel.parent, parent) + + # -- validation ---------------------------------------------------------------------------------------------- + + def test_parent_template_validation(self): + # A parent template must belong to the same device type + other_type = DeviceType.objects.create( + manufacturer=self.device_type.manufacturer, model='Other Device', slug='other-device' + ) + template = InterfaceTemplate( + device_type=other_type, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=self.parent, channel_id=1 + ) + with self.assertRaises(ValidationError): + template.full_clean() + + def test_template_channel_id_allowed_on_specific_physical_type(self): + # A channel subinterface template may keep its own specific physical type (e.g. to record the actual + # transceiver in use) instead of the generic "channel" type. + template = InterfaceTemplate( + device_type=self.device_type, name='et0:1', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS, + parent=self.parent, channel_id=1 + ) + template.full_clean() # Should not raise + + def test_template_parent_channel_id_must_be_unique(self): + InterfaceTemplate.objects.create( + device_type=self.device_type, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=self.parent, channel_id=1 + ) + duplicate = InterfaceTemplate( + device_type=self.device_type, name='et0:1b', type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=self.parent, channel_id=1 + ) + with self.assertRaises(ValidationError): + duplicate.full_clean() + + def test_template_channel_id_within_parent_range(self): + # A channel_id beyond the parent's channel count is rejected at the template level + template = InterfaceTemplate( + device_type=self.device_type, name='et0:5', type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=self.parent, channel_id=5 + ) + with self.assertRaises(ValidationError): + template.full_clean() + + def test_template_channel_requires_channelized_parent(self): + # A channel template bound to a non-channelized parent template is rejected + plain_parent = InterfaceTemplate.objects.create( + device_type=self.device_type, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS + ) + template = InterfaceTemplate( + device_type=self.device_type, name='xe0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=plain_parent, channel_id=1 + ) + with self.assertRaises(ValidationError): + template.full_clean() + + def test_template_channel_id_requires_parent(self): + # A channel_id with no parent assigned is rejected, regardless of type + template = InterfaceTemplate( + device_type=self.device_type, name='xe1', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS, + channel_id=1 + ) + with self.assertRaises(ValidationError): + template.full_clean() + + def test_template_reduce_channels_below_bound_child_rejected(self): + # Bind a channel to the highest channel of the parent, then attempt to reduce the parent's channel count + InterfaceTemplate.objects.create( + device_type=self.device_type, name='et0:4', type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=self.parent, channel_id=4 + ) + self.parent.channels = 2 + with self.assertRaises(ValidationError): + self.parent.full_clean() + + def test_template_clear_channels_with_bound_child_rejected(self): + # De-channelizing a parent template entirely is rejected while a channel subinterface template is bound to it + InterfaceTemplate.objects.create( + device_type=self.device_type, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=self.parent, channel_id=1 + ) + self.parent.channels = None + with self.assertRaises(ValidationError): + self.parent.full_clean() + + # -- renaming ------------------------------------------------------------------------------------------------ + # Renaming a channelized parent InterfaceTemplate updates the names of any channel subinterface templates + # which follow the ":" convention. + + def test_rename_updates_conforming_children(self): + child = InterfaceTemplate.objects.create( + device_type=self.device_type, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=self.parent, channel_id=1 + ) + + self.parent.name = 'et1' + with self.captureOnCommitCallbacks(execute=True): + self.parent.save() + + child.refresh_from_db() + self.assertEqual(child.name, 'et1:1') + + def test_rename_leaves_nonconforming_children_untouched(self): + child = InterfaceTemplate.objects.create( + device_type=self.device_type, name='et0-custom', type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=self.parent, channel_id=1 + ) + + self.parent.name = 'et1' + with self.captureOnCommitCallbacks(execute=True): + self.parent.save() + + child.refresh_from_db() + self.assertEqual(child.name, 'et0-custom') + + def test_rename_skips_child_on_collision(self): + colliding_child = InterfaceTemplate.objects.create( + device_type=self.device_type, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=self.parent, channel_id=1 + ) + InterfaceTemplate.objects.create( + device_type=self.device_type, name='et1:1', type=InterfaceTypeChoices.TYPE_VIRTUAL + ) + + self.parent.name = 'et1' + with self.captureOnCommitCallbacks(execute=True): + self.parent.save() + + colliding_child.refresh_from_db() + self.assertEqual(colliding_child.name, 'et0:1') + + def test_rename_does_not_collide_across_device_types(self): + # A same-named channel subinterface template under a different device type must not block the rename + other_type = DeviceType.objects.create( + manufacturer=self.device_type.manufacturer, model='Other Device', slug='other-device' + ) + InterfaceTemplate.objects.create( + device_type=other_type, name='et1:1', type=InterfaceTypeChoices.TYPE_VIRTUAL + ) + child = InterfaceTemplate.objects.create( + device_type=self.device_type, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=self.parent, channel_id=1 + ) + + self.parent.name = 'et1' + with self.captureOnCommitCallbacks(execute=True): + self.parent.save() + + child.refresh_from_db() + self.assertEqual(child.name, 'et1:1') + + def test_rename_collision_on_one_child_does_not_block_others(self): + # Each child template is renamed independently: a collision on one must not prevent another, + # non-colliding subinterface template in the same batch from being renamed. + colliding_child = InterfaceTemplate.objects.create( + device_type=self.device_type, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=self.parent, channel_id=1 + ) + clear_child = InterfaceTemplate.objects.create( + device_type=self.device_type, name='et0:2', type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=self.parent, channel_id=2 + ) + InterfaceTemplate.objects.create( + device_type=self.device_type, name='et1:1', type=InterfaceTypeChoices.TYPE_VIRTUAL + ) + + self.parent.name = 'et1' + with self.captureOnCommitCallbacks(execute=True): + self.parent.save() + + colliding_child.refresh_from_db() + clear_child.refresh_from_db() + self.assertEqual(colliding_child.name, 'et0:1') + self.assertEqual(clear_child.name, 'et1:2') + + def test_rename_cascade_is_deferred_until_transaction_commits(self): + # See the identical test on Interface: the cascade must not run until the enclosing transaction commits, + # so a sibling template saved later in the same transaction cannot silently undo it. + child = InterfaceTemplate.objects.create( + device_type=self.device_type, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=self.parent, channel_id=1 + ) + + with self.captureOnCommitCallbacks(execute=False) as callbacks: + self.parent.name = 'et1' + self.parent.save() + + child.refresh_from_db() + self.assertEqual(child.name, 'et0:1') + + stale_copy = InterfaceTemplate.objects.get(pk=child.pk) + stale_copy.save() + + for callback in callbacks: + callback() + child.refresh_from_db() + self.assertEqual(child.name, 'et1:1') + + +class ChannelizedBulkCreateTestCase(ViewTestCase): + """ + Test channel_id pattern expansion when bulk-creating channel subinterfaces (and interface templates) so that each + generated object receives a distinct channel_id. + """ + + def setUp(self): + super().setUp() + manufacturer = Manufacturer.objects.create(name='Generic', slug='generic') + self.device_type = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device', slug='test-device' + ) + role = DeviceRole.objects.create(name='Device Role', slug='device-role') + site = Site.objects.create(name='Site', slug='site') + self.device = Device.objects.create( + site=site, device_type=self.device_type, role=role, name='Device 1' + ) + + def test_bulk_create_channel_subinterfaces(self): + parent = Interface.objects.create( + device=self.device, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4 + ) + self.add_permissions('dcim.add_interface', 'dcim.view_interface') + + request_data = { + 'device': self.device.pk, + 'name': 'et0:[1-4]', + 'type': InterfaceTypeChoices.TYPE_CHANNEL, + 'parent': parent.pk, + 'channel_id': '[1-4]', + } + response = self.client.post(reverse('dcim:interface_add'), request_data) + self.assertHttpStatus(response, 302) + + # Four channel subinterfaces are created, each bound to a distinct channel on the parent + channels = Interface.objects.filter(parent=parent).order_by('channel_id') + self.assertEqual(channels.count(), 4) + for i, channel in enumerate(channels, start=1): + self.assertEqual(channel.name, f'et0:{i}') + self.assertEqual(channel.channel_id, i) + + def test_bulk_create_channel_subinterface_templates(self): + parent = InterfaceTemplate.objects.create( + device_type=self.device_type, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4 + ) + self.add_permissions('dcim.add_interfacetemplate', 'dcim.view_interfacetemplate') + + request_data = { + 'device_type': self.device_type.pk, + 'name': 'et0:[1-4]', + 'type': InterfaceTypeChoices.TYPE_CHANNEL, + 'parent': parent.pk, + 'channel_id': '[1-4]', + } + response = self.client.post(reverse('dcim:interfacetemplate_add'), request_data) + self.assertHttpStatus(response, 302) + + templates = InterfaceTemplate.objects.filter(parent=parent).order_by('channel_id') + self.assertEqual(templates.count(), 4) + for i, template in enumerate(templates, start=1): + self.assertEqual(template.name, f'et0:{i}') + self.assertEqual(template.channel_id, i) diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 007445670..7e4344f84 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1,3 +1,5 @@ +from decimal import Decimal + from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.test import TestCase @@ -8,16 +10,21 @@ from dcim.filtersets import * from dcim.models import * from ipam.choices import VLANQinQRoleChoices from ipam.models import ASN, RIR, VLAN, VRF, IPAddress, VLANTranslationPolicy -from netbox.choices import ColorChoices, WeightUnitChoices +from netbox.choices import ( + ColorChoices, + DiameterUnitChoices, + FlowRateUnitChoices, + WeightUnitChoices, +) from tenancy.models import Tenant, TenantGroup from users.models import User -from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine +from utilities.testing import ChangeLoggedFilterSetTestMixin, create_test_device, create_test_virtualmachine from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from wireless.choices import WirelessChannelChoices, WirelessRoleChoices from wireless.models import WirelessLink -class DeviceComponentFilterSetTests: +class DeviceComponentFilterSetTestMixin: def test_q(self): params = {'q': 'First'} @@ -53,7 +60,7 @@ class DeviceComponentFilterSetTests: self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class DeviceComponentTemplateFilterSetTests: +class DeviceComponentTemplateFilterSetTestMixin: def test_q(self): params = {'q': 'foobar1'} @@ -69,7 +76,7 @@ class DeviceComponentTemplateFilterSetTests: self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class RegionTestCase(TestCase, ChangeLoggedFilterSetTests): +class RegionTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = Region.objects.all() filterset = RegionFilterSet @@ -150,7 +157,7 @@ class RegionTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) -class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests): +class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = SiteGroup.objects.all() filterset = SiteGroupFilterSet @@ -229,7 +236,7 @@ class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) -class SiteTestCase(TestCase, ChangeLoggedFilterSetTests): +class SiteTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = Site.objects.all() filterset = SiteFilterSet ignore_fields = ('physical_address', 'shipping_address') @@ -388,7 +395,7 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): +class LocationTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = Location.objects.all() filterset = LocationFilterSet @@ -536,7 +543,7 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) -class RackGroupTestCase(TestCase, ChangeLoggedFilterSetTests): +class RackGroupTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = RackGroup.objects.all() filterset = RackGroupFilterSet @@ -567,7 +574,7 @@ class RackGroupTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests): +class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = RackRole.objects.all() filterset = RackRoleFilterSet @@ -602,7 +609,7 @@ class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class RackTypeTestCase(TestCase, ChangeLoggedFilterSetTests): +class RackTypeTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = RackType.objects.all() filterset = RackTypeFilterSet @@ -755,7 +762,7 @@ class RackTypeTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class RackTestCase(TestCase, ChangeLoggedFilterSetTests): +class RackTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = Rack.objects.all() filterset = RackFilterSet ignore_fields = ('units',) @@ -1129,7 +1136,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests): +class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = RackReservation.objects.all() filterset = RackReservationFilterSet ignore_fields = ('units',) @@ -1309,7 +1316,7 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class ManufacturerTestCase(TestCase, ChangeLoggedFilterSetTests): +class ManufacturerTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = Manufacturer.objects.all() filterset = ManufacturerFilterSet @@ -1340,7 +1347,7 @@ class ManufacturerTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): +class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = DeviceType.objects.all() filterset = DeviceTypeFilterSet ignore_fields = ('front_image', 'rear_image') @@ -1376,6 +1383,7 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): rear_image='rear.png', weight=10, weight_unit=WeightUnitChoices.UNIT_POUND, + end_of_life='2030-01-01', description='foobar1' ), DeviceType( @@ -1390,6 +1398,7 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, weight=20, weight_unit=WeightUnitChoices.UNIT_POUND, + end_of_life='2035-06-30', description='foobar2' ), DeviceType( @@ -1471,6 +1480,14 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'part_number': ['Part Number 1', 'Part Number 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_end_of_life(self): + params = {'end_of_life': ['2030-01-01', '2035-06-30']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'end_of_life__gte': ['2031-01-01']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'end_of_life__lte': ['2031-01-01']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_description(self): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -1582,7 +1599,7 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): +class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = ModuleType.objects.all() filterset = ModuleTypeFilterSet ignore_fields = ['attribute_data'] @@ -1630,6 +1647,8 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): weight_unit=WeightUnitChoices.UNIT_POUND, description='foobar1', airflow=ModuleAirflowChoices.FRONT_TO_REAR, + cooling_method=CoolingMethodChoices.METHOD_LIQUID, + end_of_life='2030-01-01', profile=module_type_profiles[0], attribute_data={ 'string': 'string1', @@ -1646,6 +1665,8 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): weight_unit=WeightUnitChoices.UNIT_POUND, description='foobar2', airflow=ModuleAirflowChoices.REAR_TO_FRONT, + cooling_method=CoolingMethodChoices.METHOD_HYBRID, + end_of_life='2035-06-30', profile=module_type_profiles[1], attribute_data={ 'string': 'string2', @@ -1728,6 +1749,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_end_of_life(self): + params = {'end_of_life': ['2030-01-01', '2035-06-30']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'end_of_life__gte': ['2031-01-01']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'end_of_life__lte': ['2031-01-01']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_manufacturer(self): manufacturers = Manufacturer.objects.all()[:2] params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]} @@ -1789,6 +1818,10 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'airflow': RackAirflowChoices.FRONT_TO_REAR} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_cooling_method(self): + params = {'cooling_method': CoolingMethodChoices.METHOD_LIQUID} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_profile(self): profiles = ModuleTypeProfile.objects.filter(name__startswith="Module Type Profile")[:2] params = {'profile_id': [profiles[0].pk, profiles[1].pk]} @@ -1807,7 +1840,7 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class ModuleTypeProfileTestCase(TestCase, ChangeLoggedFilterSetTests): +class ModuleTypeProfileTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = ModuleTypeProfile.objects.all() filterset = ModuleTypeProfileFilterSet ignore_fields = ['schema'] @@ -1866,7 +1899,50 @@ class ModuleTypeProfileTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class ConsolePortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests): +class ModuleBayTypeTestCase(TestCase, ChangeLoggedFilterSetTestMixin): + queryset = ModuleBayType.objects.all() + filterset = ModuleBayTypeFilterSet + + @classmethod + def setUpTestData(cls): + manufacturers = ( + Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), + Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), + Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), + ) + Manufacturer.objects.bulk_create(manufacturers) + + module_bay_types = ( + ModuleBayType(manufacturer=manufacturers[0], name='Module Bay Type 1', slug='module-bay-type-1'), + ModuleBayType(manufacturer=manufacturers[1], name='Module Bay Type 2', slug='module-bay-type-2'), + ModuleBayType(manufacturer=manufacturers[2], name='Module Bay Type 3', slug='module-bay-type-3'), + ) + ModuleBayType.objects.bulk_create(module_bay_types) + + def test_q(self): + params = {'q': 'Module Bay Type 1'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_name(self): + params = {'name': ['Module Bay Type 1', 'Module Bay Type 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_slug(self): + params = {'slug': ['module-bay-type-1', 'module-bay-type-2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_manufacturer(self): + manufacturers = Manufacturer.objects.filter(name__in=['Manufacturer 1', 'Manufacturer 2']) + params = {'manufacturer': [m.slug for m in manufacturers]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_manufacturer_id(self): + manufacturers = Manufacturer.objects.filter(name__in=['Manufacturer 1', 'Manufacturer 2']) + params = {'manufacturer_id': [m.pk for m in manufacturers]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class ConsolePortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTestMixin, ChangeLoggedFilterSetTestMixin): queryset = ConsolePortTemplate.objects.all() filterset = ConsolePortTemplateFilterSet @@ -1893,7 +1969,9 @@ class ConsolePortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTest self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class ConsoleServerPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests): +class ConsoleServerPortTemplateTestCase( + TestCase, DeviceComponentTemplateFilterSetTestMixin, ChangeLoggedFilterSetTestMixin +): queryset = ConsoleServerPortTemplate.objects.all() filterset = ConsoleServerPortTemplateFilterSet @@ -1920,7 +1998,7 @@ class ConsoleServerPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterS self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class PowerPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests): +class PowerPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTestMixin, ChangeLoggedFilterSetTestMixin): queryset = PowerPortTemplate.objects.all() filterset = PowerPortTemplateFilterSet @@ -1973,7 +2051,7 @@ class PowerPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class PowerOutletTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests): +class PowerOutletTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTestMixin, ChangeLoggedFilterSetTestMixin): queryset = PowerOutletTemplate.objects.all() filterset = PowerOutletTemplateFilterSet @@ -2026,7 +2104,143 @@ class PowerOutletTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTest self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class InterfaceTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests): +class CoolingIntakeTemplateTestCase( + TestCase, + DeviceComponentTemplateFilterSetTestMixin, + ChangeLoggedFilterSetTestMixin +): + queryset = CoolingIntakeTemplate.objects.all() + filterset = CoolingIntakeTemplateFilterSet + + @classmethod + def setUpTestData(cls): + + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + + device_types = ( + DeviceType(manufacturer=manufacturer, model='Model 1', slug='model-1'), + DeviceType(manufacturer=manufacturer, model='Model 2', slug='model-2'), + DeviceType(manufacturer=manufacturer, model='Model 3', slug='model-3'), + ) + DeviceType.objects.bulk_create(device_types) + + CoolingIntakeTemplate.objects.bulk_create(( + CoolingIntakeTemplate( + device_type=device_types[0], + name='Cooling Port 1', + type=CoolingConnectorTypeChoices.TYPE_UQD, + diameter=Decimal('25'), + diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER, + max_flow=100, + max_flow_unit=FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE, + description='foobar1' + ), + CoolingIntakeTemplate( + device_type=device_types[1], + name='Cooling Port 2', + type=CoolingConnectorTypeChoices.TYPE_QDC, + diameter=Decimal('32'), + diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER, + max_flow=200, + max_flow_unit=FlowRateUnitChoices.UNIT_CUBIC_METERS_PER_HOUR, + description='foobar2' + ), + CoolingIntakeTemplate( + device_type=device_types[2], + name='Cooling Port 3', + type=CoolingConnectorTypeChoices.TYPE_UQDB, + diameter=Decimal('40'), + diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER, + max_flow=300, + max_flow_unit=FlowRateUnitChoices.UNIT_GALLONS_PER_MINUTE, + description='foobar3' + ), + )) + + def test_name(self): + params = {'name': ['Cooling Port 1', 'Cooling Port 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_type(self): + params = {'type': [CoolingConnectorTypeChoices.TYPE_UQD]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_diameter(self): + params = {'diameter': [Decimal('25')]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_max_flow(self): + params = {'max_flow': [100, 200]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_max_flow_unit(self): + params = {'max_flow_unit': [ + FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE, FlowRateUnitChoices.UNIT_CUBIC_METERS_PER_HOUR + ]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class CoolingOutflowTemplateTestCase( + TestCase, + DeviceComponentTemplateFilterSetTestMixin, + ChangeLoggedFilterSetTestMixin +): + queryset = CoolingOutflowTemplate.objects.all() + filterset = CoolingOutflowTemplateFilterSet + + @classmethod + def setUpTestData(cls): + + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + + device_types = ( + DeviceType(manufacturer=manufacturer, model='Model 1', slug='model-1'), + DeviceType(manufacturer=manufacturer, model='Model 2', slug='model-2'), + DeviceType(manufacturer=manufacturer, model='Model 3', slug='model-3'), + ) + DeviceType.objects.bulk_create(device_types) + + CoolingOutflowTemplate.objects.bulk_create(( + CoolingOutflowTemplate( + device_type=device_types[0], + name='Cooling Outlet 1', + type=CoolingConnectorTypeChoices.TYPE_UQD, + diameter=Decimal('25'), + diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER, + description='foobar1' + ), + CoolingOutflowTemplate( + device_type=device_types[1], + name='Cooling Outlet 2', + type=CoolingConnectorTypeChoices.TYPE_QDC, + diameter=Decimal('32'), + diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER, + description='foobar2' + ), + CoolingOutflowTemplate( + device_type=device_types[2], + name='Cooling Outlet 3', + type=CoolingConnectorTypeChoices.TYPE_UQDB, + diameter=Decimal('40'), + diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER, + description='foobar3' + ), + )) + + def test_name(self): + params = {'name': ['Cooling Outlet 1', 'Cooling Outlet 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_type(self): + params = {'type': [CoolingConnectorTypeChoices.TYPE_UQD]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_diameter(self): + params = {'diameter': [Decimal('25')]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + +class InterfaceTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTestMixin, ChangeLoggedFilterSetTestMixin): queryset = InterfaceTemplate.objects.all() filterset = InterfaceTemplateFilterSet @@ -2109,7 +2323,7 @@ class InterfaceTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class FrontPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests): +class FrontPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTestMixin, ChangeLoggedFilterSetTestMixin): queryset = FrontPortTemplate.objects.all() filterset = FrontPortTemplateFilterSet @@ -2182,7 +2396,7 @@ class FrontPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class RearPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests): +class RearPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTestMixin, ChangeLoggedFilterSetTestMixin): queryset = RearPortTemplate.objects.all() filterset = RearPortTemplateFilterSet @@ -2242,7 +2456,7 @@ class RearPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class ModuleBayTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests): +class ModuleBayTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTestMixin, ChangeLoggedFilterSetTestMixin): queryset = ModuleBayTemplate.objects.all() filterset = ModuleBayTemplateFilterSet @@ -2302,7 +2516,7 @@ class ModuleBayTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class DeviceBayTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests): +class DeviceBayTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTestMixin, ChangeLoggedFilterSetTestMixin): queryset = DeviceBayTemplate.objects.all() filterset = DeviceBayTemplateFilterSet @@ -2343,7 +2557,9 @@ class DeviceBayTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class InventoryItemTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests): +class InventoryItemTemplateTestCase( + TestCase, DeviceComponentTemplateFilterSetTestMixin, ChangeLoggedFilterSetTestMixin +): queryset = InventoryItemTemplate.objects.all() filterset = InventoryItemTemplateFilterSet @@ -2453,7 +2669,7 @@ class InventoryItemTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTe self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests): +class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = DeviceRole.objects.all() filterset = DeviceRoleFilterSet @@ -2561,7 +2777,7 @@ class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) -class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests): +class PlatformTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = Platform.objects.all() filterset = PlatformFilterSet @@ -2662,7 +2878,7 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) -class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): +class DeviceTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = Device.objects.all() filterset = DeviceFilterSet ignore_fields = ('local_context_data', 'oob_ip', 'primary_ip4', 'primary_ip6', 'vc_master_for') @@ -3159,7 +3375,7 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests): +class ModuleTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = Module.objects.all() filterset = ModuleFilterSet ignore_fields = ('local_context_data',) @@ -3457,7 +3673,7 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) -class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): +class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTestMixin, ChangeLoggedFilterSetTestMixin): queryset = ConsolePort.objects.all() filterset = ConsolePortFilterSet ignore_fields = ('cable_positions',) @@ -3708,7 +3924,7 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): +class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTestMixin, ChangeLoggedFilterSetTestMixin): queryset = ConsoleServerPort.objects.all() filterset = ConsoleServerPortFilterSet ignore_fields = ('cable_positions',) @@ -3959,7 +4175,7 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): +class PowerPortTestCase(TestCase, DeviceComponentFilterSetTestMixin, ChangeLoggedFilterSetTestMixin): queryset = PowerPort.objects.all() filterset = PowerPortFilterSet ignore_fields = ('cable_positions',) @@ -4224,7 +4440,7 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): +class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTestMixin, ChangeLoggedFilterSetTestMixin): queryset = PowerOutlet.objects.all() filterset = PowerOutletFilterSet ignore_fields = ('cable_positions',) @@ -4509,7 +4725,520 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) -class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): +class CoolingIntakeTestCase(TestCase, DeviceComponentFilterSetTestMixin, ChangeLoggedFilterSetTestMixin): + queryset = CoolingIntake.objects.all() + filterset = CoolingIntakeFilterSet + + @classmethod + def setUpTestData(cls): + + regions = ( + Region(name='Region 1', slug='region-1'), + Region(name='Region 2', slug='region-2'), + Region(name='Region 3', slug='region-3'), + ) + for region in regions: + region.save() + + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + + sites = Site.objects.bulk_create(( + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), + Site(name='Site X', slug='site-x'), + )) + + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'), + ) + DeviceType.objects.bulk_create(device_types) + + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') + + roles = ( + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + DeviceRole(name='Device Role 3', slug='device-role-3'), + ) + for role in roles: + role.save() + + locations = ( + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[1]), + Location(name='Location 3', slug='location-3', site=sites[2]), + ) + for location in locations: + location.save() + + racks = ( + Rack(name='Rack 1', site=sites[0]), + Rack(name='Rack 2', site=sites[1]), + Rack(name='Rack 3', site=sites[2]), + ) + Rack.objects.bulk_create(racks) + + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + Tenant(name='Tenant 3', slug='tenant-3'), + ) + Tenant.objects.bulk_create(tenants) + + devices = ( + Device( + name='Device 1', + tenant=tenants[0], + device_type=device_types[0], + role=roles[0], + site=sites[0], + location=locations[0], + rack=racks[0], + status='active', + ), + Device( + name='Device 2', + tenant=tenants[1], + device_type=device_types[1], + role=roles[1], + site=sites[1], + location=locations[1], + rack=racks[1], + status='planned', + ), + Device( + name='Device 3', + tenant=tenants[2], + device_type=device_types[2], + role=roles[2], + site=sites[2], + location=locations[2], + rack=racks[2], + status='offline', + ), + # For cable connections + Device( + name=None, + device_type=device_types[2], + role=roles[2], + site=sites[3], + status='offline' + ), + ) + Device.objects.bulk_create(devices) + + module_bays = ( + ModuleBay(device=devices[0], name='Module Bay 1'), + ModuleBay(device=devices[1], name='Module Bay 2'), + ModuleBay(device=devices[2], name='Module Bay 3'), + ) + for module_bay in module_bays: + module_bay.save() + + modules = ( + Module(device=devices[0], module_bay=module_bays[0], module_type=module_type), + Module(device=devices[1], module_bay=module_bays[1], module_type=module_type), + Module(device=devices[2], module_bay=module_bays[2], module_type=module_type), + ) + Module.objects.bulk_create(modules) + + cooling_outflow = CoolingOutflow.objects.create(device=devices[3], name='Cooling Outlet 1') + + cooling_intakes = ( + CoolingIntake( + device=devices[0], + module=modules[0], + name='Cooling Port 1', + label='A', + type=CoolingConnectorTypeChoices.TYPE_UQD, + diameter=Decimal('25'), + diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER, + max_flow=100, + max_flow_unit=FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE, + description='First', + cooling_outflow=cooling_outflow, + _site=devices[0].site, + _location=devices[0].location, + _rack=devices[0].rack, + ), + CoolingIntake( + device=devices[1], + module=modules[1], + name='Cooling Port 2', + label='B', + type=CoolingConnectorTypeChoices.TYPE_QDC, + diameter=Decimal('32'), + diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER, + max_flow=200, + max_flow_unit=FlowRateUnitChoices.UNIT_CUBIC_METERS_PER_HOUR, + description='Second', + _site=devices[1].site, + _location=devices[1].location, + _rack=devices[1].rack, + ), + CoolingIntake( + device=devices[2], + module=modules[2], + name='Cooling Port 3', + label='C', + type=CoolingConnectorTypeChoices.TYPE_UQDB, + diameter=Decimal('40'), + diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER, + max_flow=300, + max_flow_unit=FlowRateUnitChoices.UNIT_GALLONS_PER_MINUTE, + description='Third', + _site=devices[2].site, + _location=devices[2].location, + _rack=devices[2].rack, + ), + ) + CoolingIntake.objects.bulk_create(cooling_intakes) + + def test_name(self): + params = {'name': ['Cooling Port 1', 'Cooling Port 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_description(self): + params = {'description': ['First', 'Second']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_type(self): + params = {'type': [CoolingConnectorTypeChoices.TYPE_UQD, CoolingConnectorTypeChoices.TYPE_QDC]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_cooling_outflow(self): + cooling_outflow = CoolingOutflow.objects.first() + params = {'cooling_outflow_id': [cooling_outflow.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_diameter(self): + params = {'diameter': [Decimal('25'), Decimal('32')]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_max_flow(self): + params = {'max_flow': [100, 200]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_max_flow_unit(self): + params = {'max_flow_unit': [ + FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE, FlowRateUnitChoices.UNIT_CUBIC_METERS_PER_HOUR + ]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_region(self): + regions = Region.objects.all()[:2] + params = {'region_id': [regions[0].pk, regions[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'region': [regions[0].slug, regions[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_site(self): + sites = Site.objects.all()[:2] + params = {'site_id': [sites[0].pk, sites[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site': [sites[0].slug, sites[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_location(self): + locations = Location.objects.all()[:2] + params = {'location_id': [locations[0].pk, locations[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'location': [locations[0].slug, locations[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_rack(self): + racks = Rack.objects.all()[:2] + params = {'rack_id': [racks[0].pk, racks[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'rack': [racks[0].name, racks[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_device(self): + devices = Device.objects.all()[:2] + params = {'device_id': [devices[0].pk, devices[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'device': [devices[0].name, devices[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_module(self): + modules = Module.objects.all()[:2] + params = {'module_id': [modules[0].pk, modules[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class CoolingOutflowTestCase(TestCase, DeviceComponentFilterSetTestMixin, ChangeLoggedFilterSetTestMixin): + queryset = CoolingOutflow.objects.all() + filterset = CoolingOutflowFilterSet + + @classmethod + def setUpTestData(cls): + + regions = ( + Region(name='Region 1', slug='region-1'), + Region(name='Region 2', slug='region-2'), + Region(name='Region 3', slug='region-3'), + ) + for region in regions: + region.save() + + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + + sites = Site.objects.bulk_create(( + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), + Site(name='Site X', slug='site-x'), + )) + + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'), + ) + DeviceType.objects.bulk_create(device_types) + + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') + + roles = ( + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + DeviceRole(name='Device Role 3', slug='device-role-3'), + ) + for role in roles: + role.save() + + locations = ( + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[1]), + Location(name='Location 3', slug='location-3', site=sites[2]), + ) + for location in locations: + location.save() + + racks = ( + Rack(name='Rack 1', site=sites[0]), + Rack(name='Rack 2', site=sites[1]), + Rack(name='Rack 3', site=sites[2]), + ) + Rack.objects.bulk_create(racks) + + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + Tenant(name='Tenant 3', slug='tenant-3'), + ) + Tenant.objects.bulk_create(tenants) + + devices = ( + Device( + name='Device 1', + tenant=tenants[0], + device_type=device_types[0], + role=roles[0], + site=sites[0], + location=locations[0], + rack=racks[0], + status='active', + ), + Device( + name='Device 2', + tenant=tenants[1], + device_type=device_types[1], + role=roles[1], + site=sites[1], + location=locations[1], + rack=racks[1], + status='planned', + ), + Device( + name='Device 3', + tenant=tenants[2], + device_type=device_types[2], + role=roles[2], + site=sites[2], + location=locations[2], + rack=racks[2], + status='offline', + ), + # For cable connections + Device( + name=None, + device_type=device_types[2], + role=roles[2], + site=sites[3], + status='offline' + ), + ) + Device.objects.bulk_create(devices) + + module_bays = ( + ModuleBay(device=devices[0], name='Module Bay 1'), + ModuleBay(device=devices[1], name='Module Bay 2'), + ModuleBay(device=devices[2], name='Module Bay 3'), + ) + for module_bay in module_bays: + module_bay.save() + + modules = ( + Module(device=devices[0], module_bay=module_bays[0], module_type=module_type), + Module(device=devices[1], module_bay=module_bays[1], module_type=module_type), + Module(device=devices[2], module_bay=module_bays[2], module_type=module_type), + ) + Module.objects.bulk_create(modules) + + cooling_intakes = ( + CoolingIntake(device=devices[0], name='Cooling Port 1'), + CoolingIntake(device=devices[1], name='Cooling Port 2'), + ) + CoolingIntake.objects.bulk_create(cooling_intakes) + + cooling_outflows = ( + CoolingOutflow( + device=devices[0], + module=modules[0], + name='Cooling Outlet 1', + label='A', + type=CoolingConnectorTypeChoices.TYPE_UQD, + diameter=Decimal('25'), + diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER, + description='First', + cooling_intake=cooling_intakes[0], + _site=devices[0].site, + _location=devices[0].location, + _rack=devices[0].rack, + ), + CoolingOutflow( + device=devices[1], + module=modules[1], + name='Cooling Outlet 2', + label='B', + type=CoolingConnectorTypeChoices.TYPE_QDC, + diameter=Decimal('32'), + diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER, + description='Second', + cooling_intake=cooling_intakes[1], + _site=devices[1].site, + _location=devices[1].location, + _rack=devices[1].rack, + ), + CoolingOutflow( + device=devices[2], + module=modules[2], + name='Cooling Outlet 3', + label='C', + type=CoolingConnectorTypeChoices.TYPE_UQDB, + diameter=Decimal('40'), + diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER, + description='Third', + _site=devices[2].site, + _location=devices[2].location, + _rack=devices[2].rack, + ), + ) + CoolingOutflow.objects.bulk_create(cooling_outflows) + + def test_name(self): + params = {'name': ['Cooling Outlet 1', 'Cooling Outlet 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_description(self): + params = {'description': ['First', 'Second']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_type(self): + params = {'type': [CoolingConnectorTypeChoices.TYPE_UQD, CoolingConnectorTypeChoices.TYPE_QDC]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_diameter(self): + params = {'diameter': [Decimal('25'), Decimal('32')]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_cooling_intake(self): + cooling_intakes = CoolingIntake.objects.all()[:2] + params = {'cooling_intake_id': [cooling_intakes[0].pk, cooling_intakes[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_region(self): + regions = Region.objects.all()[:2] + params = {'region_id': [regions[0].pk, regions[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'region': [regions[0].slug, regions[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_site(self): + sites = Site.objects.all()[:2] + params = {'site_id': [sites[0].pk, sites[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site': [sites[0].slug, sites[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_location(self): + locations = Location.objects.all()[:2] + params = {'location_id': [locations[0].pk, locations[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'location': [locations[0].slug, locations[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_rack(self): + racks = Rack.objects.all()[:2] + params = {'rack_id': [racks[0].pk, racks[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'rack': [racks[0].name, racks[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_device(self): + devices = Device.objects.all()[:2] + params = {'device_id': [devices[0].pk, devices[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'device': [devices[0].name, devices[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_module(self): + modules = Module.objects.all()[:2] + params = {'module_id': [modules[0].pk, modules[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class InterfaceTestCase(TestCase, DeviceComponentFilterSetTestMixin, ChangeLoggedFilterSetTestMixin): queryset = Interface.objects.all() filterset = InterfaceFilterSet ignore_fields = ('tagged_vlans', 'untagged_vlan', 'qinq_svlan', 'vdcs', 'cable_positions') @@ -5262,7 +5991,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) -class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): +class FrontPortTestCase(TestCase, DeviceComponentFilterSetTestMixin, ChangeLoggedFilterSetTestMixin): queryset = FrontPort.objects.all() filterset = FrontPortFilterSet ignore_fields = ('cable_positions',) @@ -5567,7 +6296,7 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): +class RearPortTestCase(TestCase, DeviceComponentFilterSetTestMixin, ChangeLoggedFilterSetTestMixin): queryset = RearPort.objects.all() filterset = RearPortFilterSet ignore_fields = ('cable_positions',) @@ -5857,7 +6586,7 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): +class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTestMixin, ChangeLoggedFilterSetTestMixin): queryset = ModuleBay.objects.all() filterset = ModuleBayFilterSet @@ -6046,7 +6775,7 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): +class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTestMixin, ChangeLoggedFilterSetTestMixin): queryset = DeviceBay.objects.all() filterset = DeviceBayFilterSet @@ -6243,7 +6972,7 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests): +class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = InventoryItem.objects.all() filterset = InventoryItemFilterSet @@ -6518,7 +7247,7 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class InventoryItemRoleTestCase(TestCase, ChangeLoggedFilterSetTests): +class InventoryItemRoleTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = InventoryItemRole.objects.all() filterset = InventoryItemRoleFilterSet @@ -6568,7 +7297,7 @@ class InventoryItemRoleTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class VirtualChassisTestCase(TestCase, ChangeLoggedFilterSetTests): +class VirtualChassisTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = VirtualChassis.objects.all() filterset = VirtualChassisFilterSet @@ -6668,7 +7397,7 @@ class VirtualChassisTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class CableBundleTestCase(TestCase, ChangeLoggedFilterSetTests): +class CableBundleTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = CableBundle.objects.all() filterset = CableBundleFilterSet @@ -6694,7 +7423,7 @@ class CableBundleTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class CableTestCase(TestCase, ChangeLoggedFilterSetTests): +class CableTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = Cable.objects.all() filterset = CableFilterSet @@ -7073,7 +7802,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class CableTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): +class CableTerminationTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = CableTermination.objects.all() filterset = CableTerminationFilterSet ignore_fields = ('connector', 'positions') @@ -7163,7 +7892,7 @@ class CableTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(results.first().termination_id, obj.pk) -class PowerPanelTestCase(TestCase, ChangeLoggedFilterSetTests): +class PowerPanelTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = PowerPanel.objects.all() filterset = PowerPanelFilterSet @@ -7247,7 +7976,7 @@ class PowerPanelTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests): +class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = PowerFeed.objects.all() filterset = PowerFeedFilterSet ignore_fields = ('cable_positions',) @@ -7463,7 +8192,309 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests): +class CoolingSourceTestCase(TestCase, ChangeLoggedFilterSetTestMixin): + queryset = CoolingSource.objects.all() + filterset = CoolingSourceFilterSet + + @classmethod + def setUpTestData(cls): + + regions = ( + Region(name='Region 1', slug='region-1'), + Region(name='Region 2', slug='region-2'), + Region(name='Region 3', slug='region-3'), + ) + for region in regions: + region.save() + + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + + sites = ( + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), + ) + Site.objects.bulk_create(sites) + + locations = ( + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[1]), + Location(name='Location 3', slug='location-3', site=sites[2]), + ) + for location in locations: + location.save() + + cooling_sources = ( + CoolingSource( + name='Cooling Source 1', + site=sites[0], + location=locations[0], + type=CoolingSourceTypeChoices.TYPE_CHILLER, + status=CoolingSourceStatusChoices.STATUS_ACTIVE, + fluid_type=FluidTypeChoices.FLUID_WATER, + cooling_capacity=100, + description='foobar1' + ), + CoolingSource( + name='Cooling Source 2', + site=sites[1], + location=locations[1], + type=CoolingSourceTypeChoices.TYPE_COOLING_TOWER, + status=CoolingSourceStatusChoices.STATUS_PLANNED, + fluid_type=FluidTypeChoices.FLUID_WATER, + cooling_capacity=200, + description='foobar2' + ), + CoolingSource( + name='Cooling Source 3', + site=sites[2], + location=locations[2], + type=CoolingSourceTypeChoices.TYPE_DRY_COOLER, + status=CoolingSourceStatusChoices.STATUS_OFFLINE, + fluid_type=FluidTypeChoices.FLUID_DIELECTRIC, + cooling_capacity=300, + description='foobar3' + ), + ) + for cooling_source in cooling_sources: + cooling_source.save() + + def test_q(self): + params = {'q': 'foobar1'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_name(self): + params = {'name': ['Cooling Source 1', 'Cooling Source 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_description(self): + params = {'description': ['foobar1', 'foobar2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_type(self): + params = {'type': [CoolingSourceTypeChoices.TYPE_CHILLER, CoolingSourceTypeChoices.TYPE_COOLING_TOWER]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_status(self): + params = {'status': [CoolingSourceStatusChoices.STATUS_ACTIVE, CoolingSourceStatusChoices.STATUS_PLANNED]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_fluid_type(self): + params = {'fluid_type': [FluidTypeChoices.FLUID_WATER]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_cooling_capacity(self): + params = {'cooling_capacity': [100, 200]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_region(self): + regions = Region.objects.all()[:2] + params = {'region_id': [regions[0].pk, regions[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'region': [regions[0].slug, regions[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_site(self): + sites = Site.objects.all()[:2] + params = {'site_id': [sites[0].pk, sites[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site': [sites[0].slug, sites[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_location(self): + locations = Location.objects.all()[:2] + params = {'location_id': [locations[0].pk, locations[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'location': [locations[0].slug, locations[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class CoolingFeedTestCase(TestCase, ChangeLoggedFilterSetTestMixin): + queryset = CoolingFeed.objects.all() + filterset = CoolingFeedFilterSet + + @classmethod + def setUpTestData(cls): + + regions = ( + Region(name='Region 1', slug='region-1'), + Region(name='Region 2', slug='region-2'), + Region(name='Region 3', slug='region-3'), + ) + for region in regions: + region.save() + + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + + sites = ( + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), + ) + Site.objects.bulk_create(sites) + + racks = ( + Rack(name='Rack 1', site=sites[0]), + Rack(name='Rack 2', site=sites[1]), + Rack(name='Rack 3', site=sites[2]), + ) + Rack.objects.bulk_create(racks) + + tenant_groups = ( + TenantGroup(name='Tenant group 1', slug='tenant-group-1'), + TenantGroup(name='Tenant group 2', slug='tenant-group-2'), + TenantGroup(name='Tenant group 3', slug='tenant-group-3'), + ) + for tenantgroup in tenant_groups: + tenantgroup.save() + + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]), + Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]), + Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]), + ) + Tenant.objects.bulk_create(tenants) + + cooling_sources = ( + CoolingSource(name='Cooling Source 1', site=sites[0], type=CoolingSourceTypeChoices.TYPE_CHILLER), + CoolingSource(name='Cooling Source 2', site=sites[1], type=CoolingSourceTypeChoices.TYPE_CHILLER), + CoolingSource(name='Cooling Source 3', site=sites[2], type=CoolingSourceTypeChoices.TYPE_CHILLER), + ) + CoolingSource.objects.bulk_create(cooling_sources) + + cooling_feeds = ( + CoolingFeed( + cooling_source=cooling_sources[0], + rack=racks[0], + name='Cooling Feed 1', + tenant=tenants[0], + status=CoolingFeedStatusChoices.STATUS_ACTIVE, + cooling_capacity=100, + max_flow=10, + max_flow_unit=FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE, + description='foobar1' + ), + CoolingFeed( + cooling_source=cooling_sources[1], + rack=racks[1], + name='Cooling Feed 2', + tenant=tenants[1], + status=CoolingFeedStatusChoices.STATUS_FAILED, + cooling_capacity=200, + max_flow=20, + max_flow_unit=FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE, + description='foobar2' + ), + CoolingFeed( + cooling_source=cooling_sources[2], + rack=racks[2], + name='Cooling Feed 3', + tenant=tenants[2], + status=CoolingFeedStatusChoices.STATUS_OFFLINE, + cooling_capacity=300, + max_flow=30, + max_flow_unit=FlowRateUnitChoices.UNIT_GALLONS_PER_MINUTE, + description='foobar3' + ), + ) + # Use save() rather than bulk_create() so the normalized _abs_* fields are populated + for cooling_feed in cooling_feeds: + cooling_feed.save() + + def test_q(self): + params = {'q': 'foobar1'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_name(self): + params = {'name': ['Cooling Feed 1', 'Cooling Feed 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_status(self): + params = {'status': [CoolingFeedStatusChoices.STATUS_ACTIVE, CoolingFeedStatusChoices.STATUS_FAILED]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_cooling_capacity(self): + params = {'cooling_capacity': [100, 200]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_max_flow(self): + params = {'max_flow': [10, 20]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_max_flow_unit(self): + params = {'max_flow_unit': [FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_description(self): + params = {'description': ['foobar1', 'foobar2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_region(self): + regions = Region.objects.all()[:2] + params = {'region_id': [regions[0].pk, regions[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'region': [regions[0].slug, regions[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_site(self): + sites = Site.objects.all()[:2] + params = {'site_id': [sites[0].pk, sites[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site': [sites[0].slug, sites[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_cooling_source_id(self): + cooling_sources = CoolingSource.objects.all()[:2] + params = {'cooling_source_id': [cooling_sources[0].pk, cooling_sources[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_rack_id(self): + racks = Rack.objects.all()[:2] + params = {'rack_id': [racks[0].pk, racks[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_tenant(self): + tenants = Tenant.objects.all()[:2] + params = {'tenant_id': [tenants[0].pk, tenants[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'tenant': [tenants[0].slug, tenants[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_tenant_group(self): + tenant_groups = TenantGroup.objects.all()[:2] + params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = VirtualDeviceContext.objects.all() filterset = VirtualDeviceContextFilterSet ignore_fields = ('primary_ip4', 'primary_ip6') @@ -7620,7 +8651,7 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) -class MACAddressTestCase(TestCase, ChangeLoggedFilterSetTests): +class MACAddressTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = MACAddress.objects.all() filterset = MACAddressFilterSet diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index 4177de4cf..cd1d29e7d 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -4,15 +4,19 @@ from django import forms from django.test import TestCase from dcim.choices import ( + CableEndChoices, + CableProfileChoices, DeviceFaceChoices, DeviceStatusChoices, InterfaceModeChoices, InterfaceTypeChoices, + LinkStatusChoices, PortTypeChoices, PowerOutletStatusChoices, ) from dcim.forms import * from dcim.models import * +from dcim.tests.test_module_moves import fail_after from ipam.models import ASN, RIR, VLAN from utilities.exceptions import AbortRequest from utilities.forms.rendering import M2MAddRemoveFields @@ -225,6 +229,320 @@ class ModuleTypeFormTestCase(TestCase): self.assertEqual(module_type.attribute_data, {'media': ['copper', 'qsfp28']}) +class ModuleBayTemplateImportFormTestCase(TestCase): + + def test_module_bay_types_prefers_manufacturer_specific_match_over_global(self): + """A name shared by a global and a manufacturer-scoped type resolves to the scoped one.""" + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + global_type = ModuleBayType.objects.create(name='SFP28', slug='sfp28-global') + scoped_type = ModuleBayType.objects.create( + name='SFP28', slug='sfp28-scoped', manufacturer=manufacturer, + ) + device_type = DeviceType.objects.create( + manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', + ) + + form = ModuleBayTemplateImportForm({ + 'device_type': device_type.pk, + 'name': 'Module Bay 1', + 'module_bay_types': ['SFP28'], + }) + self.assertTrue(form.is_valid(), form.errors) + + module_bay_template = form.save() + self.assertEqual( + list(module_bay_template.module_bay_types.all()), [scoped_type], + ) + self.assertNotIn(global_type, module_bay_template.module_bay_types.all()) + + def test_module_bay_types_unknown_name_raises_error(self): + device_type = DeviceType.objects.create( + manufacturer=Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1'), + model='Device Type 1', + slug='device-type-1', + ) + + form = ModuleBayTemplateImportForm({ + 'device_type': device_type.pk, + 'name': 'Module Bay 1', + 'module_bay_types': ['Nonexistent'], + }) + self.assertFalse(form.is_valid()) + self.assertEqual( + form.errors.as_data()['module_bay_types'][0].code, 'invalid_choice', + ) + + def test_module_bay_types_prefers_manufacturer_specific_match_over_global_for_module_type(self): + """Same disambiguation, but for a module bay template nested under a ModuleType.""" + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + global_type = ModuleBayType.objects.create(name='SFP28', slug='sfp28-global') + scoped_type = ModuleBayType.objects.create( + name='SFP28', slug='sfp28-scoped', manufacturer=manufacturer, + ) + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') + + form = ModuleBayTemplateImportForm({ + 'module_type': module_type.pk, + 'name': 'Module Bay 1', + 'module_bay_types': ['SFP28'], + }) + self.assertTrue(form.is_valid(), form.errors) + + module_bay_template = form.save() + self.assertEqual( + list(module_bay_template.module_bay_types.all()), [scoped_type], + ) + self.assertNotIn(global_type, module_bay_template.module_bay_types.all()) + + def test_enabled_honors_explicit_false(self): + device_type = DeviceType.objects.create( + manufacturer=Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1'), + model='Device Type 1', + slug='device-type-1', + ) + + form = ModuleBayTemplateImportForm({ + 'device_type': device_type.pk, + 'name': 'Module Bay 1', + 'enabled': False, + }) + self.assertTrue(form.is_valid(), form.errors) + self.assertFalse(form.save().enabled) + + def test_import_export_round_trip_preserves_module_bay_types(self): + """to_yaml() then re-import through this form preserves module bay types.""" + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + bay_type_a = ModuleBayType.objects.create(name='SFP28', slug='sfp28') + bay_type_b = ModuleBayType.objects.create(name='QSFP28', slug='qsfp28') + device_type = DeviceType.objects.create( + manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', + ) + original = ModuleBayTemplate.objects.create(device_type=device_type, name='Module Bay 1') + original.module_bay_types.set([bay_type_a, bay_type_b]) + + exported = original.to_yaml() + form = ModuleBayTemplateImportForm({ + 'device_type': device_type.pk, + 'name': 'Module Bay 2', + 'module_bay_types': exported['module_bay_types'], + }) + self.assertTrue(form.is_valid(), form.errors) + + reimported = form.save() + self.assertEqual( + set(reimported.module_bay_types.values_list('name', flat=True)), + set(original.module_bay_types.values_list('name', flat=True)), + ) + + def test_module_bay_types_name_belonging_only_to_other_manufacturers_is_unresolvable(self): + """ + A name that exists only for manufacturers other than the device type's own (and isn't + global) must not resolve at all -- module_bay_types is scoped to the device type's own + manufacturer plus global types, with no cross-manufacturer fallback. + """ + juniper = Manufacturer.objects.create(name='Juniper', slug='juniper') + cisco = Manufacturer.objects.create(name='Cisco', slug='cisco') + ModuleBayType.objects.create(name='SFP28', slug='sfp28-cisco', manufacturer=cisco) + device_type = DeviceType.objects.create( + manufacturer=juniper, model='Juniper Device Type', slug='juniper-device-type', + ) + + form = ModuleBayTemplateImportForm({ + 'device_type': device_type.pk, + 'name': 'Module Bay 1', + 'module_bay_types': ['SFP28'], + }) + self.assertFalse(form.is_valid()) + self.assertEqual( + form.errors.as_data()['module_bay_types'][0].code, 'invalid_choice', + ) + + def test_module_bay_types_resolution_is_independent_of_field_order(self): + """ + Resolution must not depend on the parent type having been cleaned first, so declaring + module_bay_types ahead of device_type/module_type must not change the outcome. + """ + class ReorderedImportForm(ModuleBayTemplateImportForm): + class Meta(ModuleBayTemplateImportForm.Meta): + fields = [ + 'module_bay_types', 'device_type', 'module_type', 'name', 'label', 'position', + 'enabled', 'description', + ] + + self.assertEqual(list(ReorderedImportForm().fields)[0], 'module_bay_types') + + juniper = Manufacturer.objects.create(name='Juniper', slug='juniper') + cisco = Manufacturer.objects.create(name='Cisco', slug='cisco') + global_type = ModuleBayType.objects.create(name='SFP28', slug='sfp28-global') + juniper_type = ModuleBayType.objects.create(name='SFP28', slug='sfp28-juniper', manufacturer=juniper) + cisco_type = ModuleBayType.objects.create(name='QSFP28', slug='qsfp28-cisco', manufacturer=cisco) + device_type = DeviceType.objects.create( + manufacturer=juniper, model='Juniper Device Type', slug='juniper-device-type', + ) + + # The device type's own manufacturer still wins over the global type of the same name + form = ReorderedImportForm({ + 'device_type': device_type.pk, + 'name': 'Module Bay 1', + 'module_bay_types': ['SFP28'], + }) + self.assertTrue(form.is_valid(), form.errors) + module_bay_template = form.save() + self.assertEqual(list(module_bay_template.module_bay_types.all()), [juniper_type]) + self.assertNotIn(global_type, module_bay_template.module_bay_types.all()) + + # ...and another manufacturer's bay type is still rejected rather than resolved to + form = ReorderedImportForm({ + 'device_type': device_type.pk, + 'name': 'Module Bay 2', + 'module_bay_types': [cisco_type.name], + }) + self.assertFalse(form.is_valid()) + self.assertEqual( + form.errors.as_data()['module_bay_types'][0].code, 'invalid_choice', + ) + + +class ModuleFormTestCase(TestCase): + + @classmethod + def setUpTestData(cls): + cls.device = create_test_device('Module Form Device A') + cls.device_b = create_test_device('Module Form Device B') + cls.bay_a = ModuleBay.objects.create(device=cls.device, name='Bay A') + cls.bay_b = ModuleBay.objects.create(device=cls.device, name='Bay B') + cls.bay_c = ModuleBay.objects.create(device=cls.device_b, name='Bay C') + manufacturer = Manufacturer.objects.create( + name='Module Form Manufacturer', slug='module-form-manufacturer' + ) + cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Form Type') + cls.module = Module.objects.create( + device=cls.device, module_bay=cls.bay_a, module_type=cls.module_type + ) + + def test_module_device_is_editable_on_edit(self): + form = ModuleForm(instance=self.module) + self.assertFalse(form.fields['device'].disabled) + self.assertTrue(form.fields['replicate_components'].disabled) + self.assertTrue(form.fields['adopt_components'].disabled) + + def test_module_form_moves_module_to_empty_bay(self): + form = ModuleForm( + data={ + 'device': self.device.pk, + 'module_bay': self.bay_b.pk, + 'module_type': self.module_type.pk, + 'status': 'active', + }, + instance=self.module, + ) + self.assertTrue(form.is_valid(), form.errors) + form.save() + self.module.refresh_from_db() + self.assertEqual(self.module.module_bay, self.bay_b) + + def test_module_form_rejects_occupied_bay(self): + Module.objects.create(device=self.device, module_bay=self.bay_b, module_type=self.module_type) + form = ModuleForm( + data={ + 'device': self.device.pk, + 'module_bay': self.bay_b.pk, + 'module_type': self.module_type.pk, + 'status': 'active', + }, + instance=self.module, + ) + self.assertFalse(form.is_valid()) + self.assertIn('module_bay', form.errors) + + def test_module_form_moves_module_to_different_device(self): + interface = Interface.objects.create( + device=self.device, module=self.module, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED + ) + form = ModuleForm( + data={ + 'device': self.device_b.pk, + 'module_bay': self.bay_c.pk, + 'module_type': self.module_type.pk, + 'status': 'active', + }, + instance=self.module, + ) + self.assertTrue(form.is_valid(), form.errors) + form.save() + self.module.refresh_from_db() + self.assertEqual(self.module.device, self.device_b) + self.assertEqual(self.module.module_bay, self.bay_c) + interface.refresh_from_db() + self.assertEqual(interface.device, self.device_b) + + def test_module_create_into_cyclic_hierarchy_is_rejected(self): + # CREATE into a cyclic hierarchy (bypassing clean() via .update()) must be a form error. + other_module = Module.objects.create( + device=self.device, module_bay=self.bay_b, module_type=self.module_type + ) + child_bay_1 = ModuleBay.objects.create(device=self.device, module=self.module, name='Child Bay 1') + child_bay_2 = ModuleBay.objects.create(device=self.device, module=other_module, name='Child Bay 2') + Module.objects.filter(pk=self.module.pk).update(module_bay=child_bay_2) + Module.objects.filter(pk=other_module.pk).update(module_bay=child_bay_1) + form = ModuleForm( + data={ + 'device': self.device.pk, + 'module_bay': child_bay_1.pk, + 'module_type': self.module_type.pk, + 'status': 'active', + 'replicate_components': True, + }, + ) + with fail_after(15): + self.assertFalse(form.is_valid()) + self.assertIn('contains a cycle', str(form.errors)) + + def test_module_form_reports_conflicting_cooling_component(self): + """ + A cooling component name collision must surface as a form error rather than an + IntegrityError raised from the replication insert. See netbox#15289. + """ + cooled_type = ModuleType.objects.create( + manufacturer=self.module_type.manufacturer, model='Cooled Form Type' + ) + CoolingIntakeTemplate.objects.create(module_type=cooled_type, name='Intake 1') + CoolingOutflowTemplate.objects.create(module_type=cooled_type, name='Outflow 1') + CoolingIntake.objects.create(device=self.device, name='Intake 1') + form = ModuleForm( + data={ + 'device': self.device.pk, + 'module_bay': self.bay_b.pk, + 'module_type': cooled_type.pk, + 'status': 'active', + 'replicate_components': True, + }, + ) + self.assertFalse(form.is_valid()) + self.assertIn('Intake 1', str(form.errors)) + + def test_module_form_adopts_existing_cooling_component(self): + cooled_type = ModuleType.objects.create( + manufacturer=self.module_type.manufacturer, model='Adoptable Cooled Type' + ) + CoolingIntakeTemplate.objects.create(module_type=cooled_type, name='Intake 1') + intake = CoolingIntake.objects.create(device=self.device, name='Intake 1') + form = ModuleForm( + data={ + 'device': self.device.pk, + 'module_bay': self.bay_b.pk, + 'module_type': cooled_type.pk, + 'status': 'active', + 'replicate_components': True, + 'adopt_components': True, + }, + ) + self.assertTrue(form.is_valid(), form.errors) + module = form.save() + intake.refresh_from_db() + self.assertEqual(intake.module, module) + + class VCPositionTokenFormTestCase(TestCase): @classmethod @@ -678,12 +996,373 @@ class InterfaceTestCase(TestCase): class CableTestCase(TestCase): + @classmethod + def setUpTestData(cls): + cls.site = Site.objects.create(name='Site 1', slug='site-1') + cls.device_a = create_test_device('Device A', site=cls.site) + cls.device_b = create_test_device('Device B', site=cls.site) + cls.device_c = create_test_device('Device C', site=cls.site) + + cls.interfaces_a = ( + Interface(device=cls.device_a, name='et-0/0/0', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=cls.device_a, name='et-0/0/1', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + ) + cls.interfaces_b = ( + Interface(device=cls.device_b, name='et-0/0/0', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=cls.device_b, name='et-0/0/1', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=cls.device_b, name='et-0/0/2', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + ) + cls.interface_c = Interface(device=cls.device_c, name='et-0/0/1', type=InterfaceTypeChoices.TYPE_1GE_FIXED) + Interface.objects.bulk_create([*cls.interfaces_a, *cls.interfaces_b, cls.interface_c]) + + cls.power_panel = PowerPanel.objects.create(site=cls.site, name='Power Panel 1') + cls.power_feeds = ( + PowerFeed(power_panel=cls.power_panel, name='Power Feed 1'), + PowerFeed(power_panel=cls.power_panel, name='Power Feed 2'), + ) + PowerFeed.objects.bulk_create(cls.power_feeds) + cls.power_ports = ( + PowerPort(device=cls.device_b, name='Power Port 1'), + PowerPort(device=cls.device_b, name='Power Port 2'), + ) + PowerPort.objects.bulk_create(cls.power_ports) + def test_invalid_side_designation_raises_value_error(self): """_clean_side rejects a side other than 'a' or 'b' with ValueError.""" form = CableImportForm.__new__(CableImportForm) with self.assertRaisesMessage(ValueError, "Invalid side designation: c"): form._clean_side('c') + def test_import_single_termination_cable(self): + """A single-value cell per side resolves one termination per side.""" + form = CableImportForm(data={ + 'side_a_site': 'Site 1', + 'side_a_device': 'Device A', + 'side_a_type': 'dcim.interface', + 'side_a_name': 'et-0/0/0', + 'side_b_site': 'Site 1', + 'side_b_device': 'Device B', + 'side_b_type': 'dcim.interface', + 'side_b_name': 'et-0/0/0', + 'status': LinkStatusChoices.STATUS_CONNECTED, + }) + self.assertTrue(form.is_valid(), form.errors) + cable = form.save() + self.assertEqual(cable.a_terminations, [self.interfaces_a[0]]) + self.assertEqual(cable.b_terminations, [self.interfaces_b[0]]) + + def test_import_multiple_terminations_single_parent(self): + """A single parent value is reused for all comma-separated termination names.""" + form = CableImportForm(data={ + 'side_a_device': 'Device A', + 'side_a_type': 'dcim.interface', + 'side_a_name': 'et-0/0/0', + 'side_b_device': 'Device B', + 'side_b_type': 'dcim.interface', + 'side_b_name': 'et-0/0/1, et-0/0/2', + 'status': LinkStatusChoices.STATUS_CONNECTED, + 'profile': CableProfileChoices.BREAKOUT_1C2P_2C1P, + }) + self.assertTrue(form.is_valid(), form.errors) + cable = form.save() + self.assertEqual(cable.a_terminations, [self.interfaces_a[0]]) + self.assertEqual(cable.b_terminations, [self.interfaces_b[1], self.interfaces_b[2]]) + + def test_import_multiple_terminations_multiple_parents_preserves_order(self): + """Pairwise parent/name lists resolve in submitted order, driving connector assignment.""" + form = CableImportForm(data={ + 'side_a_device': 'Device A', + 'side_a_type': 'dcim.interface', + 'side_a_name': 'et-0/0/0', + 'side_b_device': 'Device C,Device B', + 'side_b_type': 'dcim.interface', + 'side_b_name': 'et-0/0/1,et-0/0/1', + 'status': LinkStatusChoices.STATUS_CONNECTED, + 'profile': CableProfileChoices.BREAKOUT_1C2P_2C1P, + }) + self.assertTrue(form.is_valid(), form.errors) + cable = form.save() + self.assertEqual(cable.b_terminations, [self.interface_c, self.interfaces_b[1]]) + + cable_terminations = CableTermination.objects.filter( + cable=cable, cable_end=CableEndChoices.SIDE_B + ).order_by('connector') + self.assertEqual([ct.termination for ct in cable_terminations], [self.interface_c, self.interfaces_b[1]]) + self.assertEqual([ct.connector for ct in cable_terminations], [1, 2]) + + def test_import_multiple_terminations_parent_count_mismatch(self): + """A parent list that is neither one value nor one per termination name is rejected.""" + form = CableImportForm(data={ + 'side_a_device': 'Device A', + 'side_a_type': 'dcim.interface', + 'side_a_name': 'et-0/0/0', + 'side_b_device': 'Device B,Device C', + 'side_b_type': 'dcim.interface', + 'side_b_name': 'et-0/0/1,et-0/0/1,et-0/0/2', + 'status': LinkStatusChoices.STATUS_CONNECTED, + }) + self.assertFalse(form.is_valid()) + self.assertIn('Must specify either one device', str(form.errors.get('side_b_name'))) + + def test_import_multiple_terminations_duplicate_termination(self): + """The same termination cannot be listed twice on one cable end.""" + form = CableImportForm(data={ + 'side_a_device': 'Device A', + 'side_a_type': 'dcim.interface', + 'side_a_name': 'et-0/0/0', + 'side_b_device': 'Device B', + 'side_b_type': 'dcim.interface', + 'side_b_name': 'et-0/0/1,et-0/0/1', + 'status': LinkStatusChoices.STATUS_CONNECTED, + }) + self.assertFalse(form.is_valid()) + self.assertIn('Duplicate termination', str(form.errors.get('side_b_name'))) + + def test_import_terminations_exceeding_profile_capacity(self): + """A side carrying more terminations than its profile permits reports against that side's column.""" + form = CableImportForm(data={ + 'side_a_device': 'Device A', + 'side_a_type': 'dcim.interface', + 'side_a_name': 'et-0/0/0', + 'side_b_device': 'Device B', + 'side_b_type': 'dcim.interface', + 'side_b_name': 'et-0/0/0,et-0/0/1,et-0/0/2', + 'status': LinkStatusChoices.STATUS_CONNECTED, + 'profile': CableProfileChoices.BREAKOUT_1C2P_2C1P, + }) + self.assertFalse(form.is_valid()) + self.assertIn('only 2 are permitted', str(form.errors.get('side_b_name'))) + + def test_import_terminations_exceeding_profile_capacity_side_a(self): + """The same applies to side A, whose profile capacity is often lower than side B's.""" + form = CableImportForm(data={ + 'side_a_device': 'Device A', + 'side_a_type': 'dcim.interface', + 'side_a_name': 'et-0/0/0,et-0/0/1', + 'side_b_device': 'Device B', + 'side_b_type': 'dcim.interface', + 'side_b_name': 'et-0/0/0', + 'status': LinkStatusChoices.STATUS_CONNECTED, + 'profile': CableProfileChoices.BREAKOUT_1C2P_2C1P, + }) + self.assertFalse(form.is_valid()) + self.assertIn('only 1 are permitted', str(form.errors.get('side_a_name'))) + + def test_import_multiple_terminations_empty_name(self): + """A trailing comma produces an empty termination name and is rejected.""" + form = CableImportForm(data={ + 'side_a_device': 'Device A', + 'side_a_type': 'dcim.interface', + 'side_a_name': 'et-0/0/0', + 'side_b_device': 'Device B', + 'side_b_type': 'dcim.interface', + 'side_b_name': 'et-0/0/1,', + 'status': LinkStatusChoices.STATUS_CONNECTED, + }) + self.assertFalse(form.is_valid()) + self.assertIn('Empty termination names', str(form.errors.get('side_b_name'))) + + def test_import_multiple_terminations_connected_termination(self): + """An already-cabled termination in a multi-value list is rejected.""" + cable = Cable(a_terminations=[self.interfaces_a[1]], b_terminations=[self.interfaces_b[1]]) + cable.save() + + form = CableImportForm(data={ + 'side_a_device': 'Device A', + 'side_a_type': 'dcim.interface', + 'side_a_name': 'et-0/0/0', + 'side_b_device': 'Device B', + 'side_b_type': 'dcim.interface', + 'side_b_name': 'et-0/0/1,et-0/0/2', + 'status': LinkStatusChoices.STATUS_CONNECTED, + }) + self.assertFalse(form.is_valid()) + self.assertIn('already connected', str(form.errors.get('side_b_name'))) + + def test_import_multiple_terminations_power_feeds(self): + """Multiple power feeds import from a single broadcast power panel.""" + form = CableImportForm(data={ + 'side_a_power_panel': 'Power Panel 1', + 'side_a_type': 'dcim.powerfeed', + 'side_a_name': 'Power Feed 1,Power Feed 2', + 'side_b_device': 'Device B', + 'side_b_type': 'dcim.powerport', + 'side_b_name': 'Power Port 1,Power Port 2', + 'status': LinkStatusChoices.STATUS_CONNECTED, + }) + self.assertTrue(form.is_valid(), form.errors) + cable = form.save() + self.assertEqual(cable.a_terminations, list(self.power_feeds)) + self.assertEqual(cable.b_terminations, list(self.power_ports)) + + def test_import_multiple_terminations_repeated_parent_values(self): + """A repeated parent in a pairwise list resolves per position, not deduplicated.""" + form = CableImportForm(data={ + 'side_a_device': 'Device A', + 'side_a_type': 'dcim.interface', + 'side_a_name': 'et-0/0/0', + 'side_b_device': 'Device B,Device C,Device B', + 'side_b_type': 'dcim.interface', + 'side_b_name': 'et-0/0/1,et-0/0/1,et-0/0/2', + 'status': LinkStatusChoices.STATUS_CONNECTED, + }) + self.assertTrue(form.is_valid(), form.errors) + cable = form.save() + self.assertEqual( + cable.b_terminations, + [self.interfaces_b[1], self.interface_c, self.interfaces_b[2]] + ) + + def test_import_multiple_terminations_native_lists(self): + """Native list values (JSON/YAML import) resolve like comma-separated cells.""" + form = CableImportForm(data={ + 'side_a_device': 'Device A', + 'side_a_type': 'dcim.interface', + 'side_a_name': 'et-0/0/0', + 'side_b_device': ['Device C', 'Device B'], + 'side_b_type': 'dcim.interface', + 'side_b_name': ['et-0/0/1', 'et-0/0/1'], + 'status': LinkStatusChoices.STATUS_CONNECTED, + 'profile': CableProfileChoices.BREAKOUT_1C2P_2C1P, + }) + self.assertTrue(form.is_valid(), form.errors) + cable = form.save() + self.assertEqual(cable.b_terminations, [self.interface_c, self.interfaces_b[1]]) + + def test_import_multiple_terminations_unknown_parent(self): + """An unknown parent in a multi-value cell errors on the parent field only.""" + form = CableImportForm(data={ + 'side_a_device': 'Device A', + 'side_a_type': 'dcim.interface', + 'side_a_name': 'et-0/0/0', + 'side_b_device': 'Device B,Device X', + 'side_b_type': 'dcim.interface', + 'side_b_name': 'et-0/0/1,et-0/0/2', + 'status': LinkStatusChoices.STATUS_CONNECTED, + }) + self.assertFalse(form.is_valid()) + self.assertIn('Object not found: Device X', str(form.errors.get('side_b_device'))) + self.assertNotIn('side_b_name', form.errors) + + def test_import_multiple_terminations_missing_parent(self): + """A device component termination type without a device value is rejected.""" + form = CableImportForm(data={ + 'side_a_device': 'Device A', + 'side_a_type': 'dcim.interface', + 'side_a_name': 'et-0/0/0', + 'side_b_type': 'dcim.interface', + 'side_b_name': 'et-0/0/1,et-0/0/2', + 'status': LinkStatusChoices.STATUS_CONNECTED, + }) + self.assertFalse(form.is_valid()) + self.assertIn('Must specify a device', str(form.errors.get('side_b_name'))) + + def test_import_unsupported_termination_type(self): + """Termination types without a supported parent field are rejected.""" + form = CableImportForm(data={ + 'side_a_device': 'Device A', + 'side_a_type': 'dcim.interface', + 'side_a_name': 'et-0/0/0', + 'side_b_device': 'Device B', + 'side_b_type': 'circuits.circuittermination', + 'side_b_name': 'Termination X', + 'status': LinkStatusChoices.STATUS_CONNECTED, + }) + self.assertFalse(form.is_valid()) + self.assertIn('Bulk import does not support', str(form.errors.get('side_b_name'))) + + def test_import_unknown_termination_type(self): + """An unresolvable termination type errors on the type field only.""" + form = CableImportForm(data={ + 'side_a_device': 'Device A', + 'side_a_type': 'dcim.interface', + 'side_a_name': 'et-0/0/0', + 'side_b_device': 'Device B', + 'side_b_type': 'dcim.nosuchmodel', + 'side_b_name': 'et-0/0/1', + 'status': LinkStatusChoices.STATUS_CONNECTED, + }) + self.assertFalse(form.is_valid()) + self.assertIn('side_b_type', form.errors) + self.assertNotIn('side_b_name', form.errors) + + def test_import_multiple_terminations_unknown_name(self): + """An unknown termination name in a multi-value list is rejected.""" + form = CableImportForm(data={ + 'side_a_device': 'Device A', + 'side_a_type': 'dcim.interface', + 'side_a_name': 'et-0/0/0', + 'side_b_device': 'Device B', + 'side_b_type': 'dcim.interface', + 'side_b_name': 'et-0/0/1,et-0/0/9', + 'status': LinkStatusChoices.STATUS_CONNECTED, + }) + self.assertFalse(form.is_valid()) + self.assertIn('side termination not found', str(form.errors.get('side_b_name'))) + + def test_import_multiple_terminations_ambiguous_parent(self): + """A parent name matching multiple objects errors on the parent field.""" + site_2 = Site.objects.create(name='Site 2', slug='site-2') + create_test_device('Device D', site=self.site) + create_test_device('Device D', site=site_2) + + form = CableImportForm(data={ + 'side_a_device': 'Device A', + 'side_a_type': 'dcim.interface', + 'side_a_name': 'et-0/0/0', + 'side_b_device': 'Device D', + 'side_b_type': 'dcim.interface', + 'side_b_name': 'et-0/0/1', + 'status': LinkStatusChoices.STATUS_CONNECTED, + }) + self.assertFalse(form.is_valid()) + self.assertIn('is not a unique value', str(form.errors.get('side_b_device'))) + self.assertNotIn('side_b_name', form.errors) + + def test_import_multiple_terminations_site_filtered_parent_queryset(self): + """Parent resolution honors side_x_site queryset filtering for multi-value parents.""" + site_2 = Site.objects.create(name='Site 2', slug='site-2') + device_x = create_test_device('Device X', site=site_2) + Interface.objects.create(device=device_x, name='et-0/0/1', type=InterfaceTypeChoices.TYPE_1GE_FIXED) + + form = CableImportForm(data={ + 'side_a_site': 'Site 1', + 'side_a_device': 'Device A', + 'side_a_type': 'dcim.interface', + 'side_a_name': 'et-0/0/0', + 'side_b_site': 'Site 1', + 'side_b_device': 'Device B,Device X', + 'side_b_type': 'dcim.interface', + 'side_b_name': 'et-0/0/1,et-0/0/1', + 'status': LinkStatusChoices.STATUS_CONNECTED, + }) + self.assertFalse(form.is_valid()) + self.assertIn('Object not found: Device X', str(form.errors.get('side_b_device'))) + self.assertNotIn('side_b_name', form.errors) + + def test_import_ambiguous_vc_component(self): + """A component name found on multiple VC members produces a form error.""" + vc = VirtualChassis.objects.create(name='Virtual Chassis 1') + master = create_test_device('VC Master', site=self.site, virtual_chassis=vc, vc_position=1) + member_2 = create_test_device('VC Member 2', site=self.site, virtual_chassis=vc, vc_position=2) + member_3 = create_test_device('VC Member 3', site=self.site, virtual_chassis=vc, vc_position=3) + vc.master = master + vc.save() + Interface.objects.create(device=member_2, name='vc-eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED) + Interface.objects.create(device=member_3, name='vc-eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED) + + form = CableImportForm(data={ + 'side_a_device': 'Device A', + 'side_a_type': 'dcim.interface', + 'side_a_name': 'et-0/0/0', + 'side_b_device': 'VC Master', + 'side_b_type': 'dcim.interface', + 'side_b_name': 'vc-eth0', + 'status': LinkStatusChoices.STATUS_CONNECTED, + }) + self.assertFalse(form.is_valid()) + self.assertIn('side termination not unique', str(form.errors.get('side_b_name'))) + class SiteFormTestCase(TestCase): """ diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index f81e88f1c..3c870f4ec 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -1,10 +1,9 @@ from decimal import Decimal -from unittest.mock import patch from django.core.exceptions import ValidationError from django.db.models import ProtectedError from django.db.models.signals import post_save -from django.test import TestCase, override_settings, tag +from django.test import TestCase, tag from circuits.models import * from core.models import ObjectType @@ -13,10 +12,9 @@ from dcim.models import * from extras.events import serialize_for_event from extras.models import CustomField from ipam.models import Prefix -from netbox.choices import WeightUnitChoices +from netbox.choices import DiameterUnitChoices, FlowRateUnitChoices, WeightUnitChoices from tenancy.models import Tenant from utilities.data import drange -from utilities.testing import PinnedConnectionRouter from virtualization.models import Cluster, ClusterType @@ -56,6 +54,67 @@ class MACAddressTestCase(TestCase): self.mac_b.assigned_object = None self.mac_b.clean() + def test_set_primary_mac_address_assigns(self): + self.interface.set_primary_mac_address(self.mac_b) + self.interface.refresh_from_db() + self.assertEqual(self.interface.primary_mac_address_id, self.mac_b.pk) + + def test_set_primary_mac_address_clears(self): + self.interface.set_primary_mac_address(None) + self.interface.refresh_from_db() + self.assertIsNone(self.interface.primary_mac_address_id) + + def test_set_primary_mac_address_noop_when_already_primary(self): + # mac_a is already primary; re-setting it changes nothing and doesn't error. + self.interface.set_primary_mac_address(self.mac_a) + self.interface.refresh_from_db() + self.assertEqual(self.interface.primary_mac_address_id, self.mac_a.pk) + + def test_set_primary_mac_address_from_value_finds_existing(self): + # A value already present on the interface is promoted, not duplicated. + count_before = self.interface.mac_addresses.count() + self.interface.set_primary_mac_address_from_value(str(self.mac_b.mac_address)) + self.interface.refresh_from_db() + self.assertEqual(self.interface.primary_mac_address_id, self.mac_b.pk) + self.assertEqual(self.interface.mac_addresses.count(), count_before) + + def test_set_primary_mac_address_from_value_creates(self): + count_before = self.interface.mac_addresses.count() + self.interface.set_primary_mac_address_from_value('aabbccddeeff') + self.interface.refresh_from_db() + self.assertEqual(self.interface.mac_addresses.count(), count_before + 1) + self.assertEqual(str(self.interface.primary_mac_address.mac_address).lower(), 'aa:bb:cc:dd:ee:ff') + + def test_set_primary_mac_address_rejects_foreign_mac(self): + # A MAC assigned to a different interface can't be made primary here. + other = Interface.objects.create( + device=self.interface.device, + name='Interface 2', + type=InterfaceTypeChoices.TYPE_1GE_FIXED, + ) + foreign_mac = MACAddress.objects.create(mac_address='ffeeddccbbaa', assigned_object=other) + with self.assertRaises(ValidationError): + self.interface.set_primary_mac_address(foreign_mac) + + def test_clean_rejects_unassigned_primary_mac_on_update(self): + # An existing interface can't point its primary at a MAC that isn't assigned to it. + unassigned = MACAddress.objects.create(mac_address='aabbccdd0099') + self.interface.primary_mac_address = unassigned + with self.assertRaises(ValidationError): + self.interface.full_clean() + + def test_clean_allows_unassigned_primary_mac_on_create(self): + # On create the MAC is assigned by a post_save signal after clean(), so an as-yet-unassigned + # primary MAC must pass validation on a new (adding) instance. + mac = MACAddress.objects.create(mac_address='aabbccdd00aa') + new_iface = Interface( + device=self.interface.device, + name='Interface Create Heal', + type=InterfaceTypeChoices.TYPE_1GE_FIXED, + primary_mac_address=mac, + ) + new_iface.full_clean() # must not raise + class LocationTestCase(TestCase): @@ -184,6 +243,34 @@ class ModuleTypeTestCase(TestCase): module_type.refresh_from_db() self.assertEqual(module_type.interface_template_count, 1) + def test_module_bay_template_to_yaml_includes_module_bay_types(self): + """ + ModuleBayTemplate.to_yaml() should export its assigned module bay types by name. + """ + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') + bay_type = ModuleBayType.objects.create(name='SFP28', slug='sfp28') + module_bay_template = ModuleBayTemplate.objects.create(module_type=module_type, name='Module Bay 1') + module_bay_template.module_bay_types.set([bay_type]) + + data = module_bay_template.to_yaml() + self.assertEqual(data['module_bay_types'], ['SFP28']) + + def test_module_bay_template_to_yaml_orders_module_bay_types(self): + """ + Multiple module bay types should export in ModuleBayType's own ordering + (manufacturer, name), independent of assignment order. + """ + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') + bay_type_b = ModuleBayType.objects.create(name='QSFP28', slug='qsfp28') + bay_type_a = ModuleBayType.objects.create(name='SFP28', slug='sfp28') + module_bay_template = ModuleBayTemplate.objects.create(module_type=module_type, name='Module Bay 1') + module_bay_template.module_bay_types.set([bay_type_b, bay_type_a]) + + data = module_bay_template.to_yaml() + self.assertEqual(data['module_bay_types'], ['QSFP28', 'SFP28']) + def test_attributes(self): """ ModuleType.attributes should normalize iterable values into strings for presentation. @@ -245,6 +332,8 @@ class RackTypeTestCase(TestCase): weight_unit=WeightUnitChoices.UNIT_POUND, max_weight=7777, mounting_depth=8, + cooling_capability=RackCoolingCapabilityChoices.CAPABILITY_LIQUID_ONLY, + cooling_capacity=80, ) def test_rack_creation(self): @@ -264,7 +353,7 @@ class RackTypeTestCase(TestCase): facility_id='A101', site=sites[0], location=locations[0], - rack_type=rack_type + rack_type=rack_type, ) self.assertEqual(rack.width, rack_type.width) self.assertEqual(rack.u_height, rack_type.u_height) @@ -277,6 +366,9 @@ class RackTypeTestCase(TestCase): self.assertEqual(rack.weight_unit, rack_type.weight_unit) self.assertEqual(rack.max_weight, rack_type.max_weight) self.assertEqual(rack.mounting_depth, rack_type.mounting_depth) + # Cooling capability/capacity are inherited from the rack type + self.assertEqual(rack.cooling_capability, rack_type.cooling_capability) + self.assertEqual(rack.cooling_capacity, rack_type.cooling_capacity) class RackTestCase(TestCase): @@ -1063,9 +1155,10 @@ class ModuleBayTestCase(TestCase): module_bay_1.clean() module_bay_1.save() - # Confirm error if Module recurses - with self.assertRaises(ValidationError): - module_1.module_bay = module_bay_3 + # Confirm error if Module recurses (empty target bay, so the occupied-bay check cannot mask it) + module_bay_4 = ModuleBay.objects.create(device=module_1.device, name='Module Bay 4', module=module_3) + with self.assertRaisesMessage(ValidationError, 'cannot belong to a module installed within it'): + module_1.module_bay = module_bay_4 module_1.clean() module_1.save() @@ -1146,18 +1239,17 @@ class ModuleBayTestCase(TestCase): self.assertEqual(names, ['Bay 1', 'Bay 1.1', 'Bay 1.2', 'Bay 1.3']) @tag('regression') # #22146 - def test_root_module_bay_rename_preserves_tree_ids(self): + def test_root_module_bay_rename_preserves_paths(self): """ - Renaming a root module bay must not renumber any other root tree's - tree_id. The renamed bay's own tree_id is also expected to remain - stable, but the load-bearing assertion is that the *other* bays are - not shifted. + Renaming a root module bay must not rewrite any tree's path. Renaming + touches only sort_path (the display-ordering column), so every bay's + path — including the renamed bay's own — must be unchanged afterward. """ device_type = DeviceType.objects.first() device_role = DeviceRole.objects.first() site = Site.objects.first() device = Device.objects.create( - name='Rename TreeID Device', + name='Rename Path Device', device_type=device_type, role=device_role, site=site, @@ -1165,8 +1257,8 @@ class ModuleBayTestCase(TestCase): for name in ('Bay 1', 'Bay 2', 'Bay 3', 'Bay 4'): ModuleBay.objects.create(device=device, name=name) - tree_ids_before = { - bay.name: bay.tree_id + paths_before = { + bay.pk: str(bay.path) for bay in ModuleBay.objects.filter(device=device) } @@ -1174,18 +1266,16 @@ class ModuleBayTestCase(TestCase): bay.name = 'Bay 99' bay.save() - tree_ids_after = { - bay.name: bay.tree_id + paths_after = { + bay.pk: str(bay.path) for bay in ModuleBay.objects.filter(device=device) } - for name in ('Bay 1', 'Bay 3', 'Bay 4'): - self.assertEqual(tree_ids_after[name], tree_ids_before[name]) - self.assertEqual(tree_ids_after['Bay 99'], tree_ids_before['Bay 2']) + self.assertEqual(paths_after, paths_before) @tag('regression') # #22146 def test_root_module_bay_rename_updates_display_order(self): """ - Even though renaming a root module bay does not renumber tree_ids, + Even though renaming a root module bay does not rewrite its path, the manager's _root_name annotation must reflect the new name so the display ordering is correct. """ @@ -1275,15 +1365,17 @@ class ModuleBayTestCase(TestCase): movable_bay.refresh_from_db() host_bay.refresh_from_db() self.assertEqual(movable_bay.parent_id, host_bay.pk) - self.assertEqual(movable_bay.tree_id, host_bay.tree_id) + # The trigger cascade must have re-rooted the moved bay into host_bay's + # tree: its path is now a strict descendant of host_bay's path. + self.assertTrue(str(movable_bay.path).startswith(f'{host_bay.path}.')) @tag('regression') # #22251 def test_moving_module_reparents_child_module_bays(self): """ When a module is moved to a different module bay, each child ModuleBay - (a bay that belongs to the module) must have its MPTT parent updated - to the new host bay. Without the fix the children stay parented to the - old bay even though Module.module_bay_id has changed. + (a bay that belongs to the module) must have its parent updated to the + new host bay. Without the fix the children stay parented to the old bay + even though Module.module_bay_id has changed. """ device_type = DeviceType.objects.first() device_role = DeviceRole.objects.first() @@ -1318,19 +1410,20 @@ class ModuleBayTestCase(TestCase): child_2.refresh_from_db() self.assertEqual(child_1.parent_id, bay_b.pk) self.assertEqual(child_2.parent_id, bay_b.pk) - # Children must share the same MPTT tree as their new parent. + # Children must be re-rooted under bay_b in the ltree hierarchy. bay_b.refresh_from_db() - self.assertEqual(child_1.tree_id, bay_b.tree_id) - self.assertEqual(child_2.tree_id, bay_b.tree_id) + self.assertTrue(str(child_1.path).startswith(f'{bay_b.path}.')) + self.assertTrue(str(child_2.path).startswith(f'{bay_b.path}.')) @tag('regression') # #22251 def test_moving_module_reparents_grandchild_module_bays(self): """ When a module is moved, grandchild ModuleBays (bays inside a module that is itself installed inside a child bay of the moved module) must - also land in the new MPTT tree. MPTT moves subtrees atomically, so - calling save() only on direct children is sufficient — this test - documents and preserves that invariant for future tree-backend changes. + also land in the new ltree subtree. The trigger cascade moves subtrees + atomically, so calling save() only on direct children is sufficient — + this test documents and preserves that invariant for future tree-backend + changes. """ device_type = DeviceType.objects.first() device_role = DeviceRole.objects.first() @@ -1358,7 +1451,8 @@ class ModuleBayTestCase(TestCase): self.assertEqual(child_bay.parent_id, bay_a.pk) self.assertEqual(grandchild_bay.parent_id, child_bay.pk) - self.assertEqual(grandchild_bay.tree_id, bay_a.tree_id) + bay_a.refresh_from_db() + self.assertTrue(str(grandchild_bay.path).startswith(f'{bay_a.path}.')) # Move the top-level module to bay_b. module_1.module_bay = bay_b @@ -1369,10 +1463,10 @@ class ModuleBayTestCase(TestCase): bay_b.refresh_from_db() self.assertEqual(child_bay.parent_id, bay_b.pk) - self.assertEqual(child_bay.tree_id, bay_b.tree_id) + self.assertTrue(str(child_bay.path).startswith(f'{bay_b.path}.')) # Grandchild's direct parent (child_bay) is unchanged; only tree placement moves. self.assertEqual(grandchild_bay.parent_id, child_bay.pk) - self.assertEqual(grandchild_bay.tree_id, bay_b.tree_id) + self.assertTrue(str(grandchild_bay.path).startswith(f'{bay_b.path}.')) def test_single_module_token(self): device_type = DeviceType.objects.first() @@ -1451,6 +1545,38 @@ class ModuleBayTestCase(TestCase): nested_bay = module.modulebays.get(name='SFP A-21') self.assertEqual(nested_bay.label, 'A-21') + @tag('regression') # #21418 + def test_module_install_nests_module_bay_parent(self): + """ + A module bay instantiated when a module is installed must be nested under the + installing module's bay. bulk_create() bypasses ModuleBay.save(), so the parent + is assigned in ModuleBayTemplate.instantiate(); without it the bay would be left + a root with a top-level ltree path. + """ + manufacturer = Manufacturer.objects.first() + site = Site.objects.first() + device_role = DeviceRole.objects.first() + + device_type = DeviceType.objects.create( + manufacturer=manufacturer, model='Chassis with Bay', slug='chassis-with-bay' + ) + ModuleBayTemplate.objects.create(device_type=device_type, name='Bay A') + + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module with Sub-bay') + ModuleBayTemplate.objects.create(module_type=module_type, name='Sub-bay 1') + + device = Device.objects.create( + name='Nested Bay Parent Device', device_type=device_type, role=device_role, site=site + ) + parent_bay = device.modulebays.get(name='Bay A') + module = Module.objects.create(device=device, module_bay=parent_bay, module_type=module_type) + + nested_bay = module.modulebays.get(name='Sub-bay 1') + self.assertEqual(nested_bay.parent, parent_bay) + # The ltree path/level must reflect the nesting, not a root placement. + self.assertEqual(nested_bay.level, parent_bay.level + 1) + self.assertTrue(str(nested_bay.path).startswith(f'{parent_bay.path}.')) + @tag('regression') # #20467 def test_nested_module_bay_position_resolution(self): """Test that {module} in a module bay template's position field is resolved when the module is installed.""" @@ -1944,6 +2070,160 @@ class ModuleBayTestCase(TestCase): self.assertIn('disabled module bay', str(cm.exception.message_dict['module_bay'])) +class ModuleBayTypeCompatibilityTestCase(TestCase): + """Tests for bay type compatibility: Module.is_bay_compatible, ModuleType.get_incompatible_modules, + ModuleBay.is_module_compatible, and Module.clean() validation.""" + + @classmethod + def setUpTestData(cls): + site = Site.objects.create(name='Compat Site', slug='compat-site') + manufacturer = Manufacturer.objects.create(name='Compat Mfr', slug='compat-mfr') + device_type = DeviceType.objects.create( + manufacturer=manufacturer, model='Compat Device Type', slug='compat-dt' + ) + device_role = DeviceRole.objects.create(name='Compat Role', slug='compat-role') + cls.device = Device.objects.create( + name='Compat Device', device_type=device_type, role=device_role, site=site + ) + + cls.bay_type_a = ModuleBayType.objects.create( + manufacturer=manufacturer, name='Bay Type A', slug='bay-type-a' + ) + cls.bay_type_b = ModuleBayType.objects.create( + manufacturer=manufacturer, name='Bay Type B', slug='bay-type-b' + ) + + cls.module_type_a = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type A') + cls.module_type_a.module_bay_types.set([cls.bay_type_a]) + + cls.module_type_b = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type B') + cls.module_type_b.module_bay_types.set([cls.bay_type_b]) + + cls.module_type_any = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type Any') + + def _make_bay(self, name, *bay_types): + bay = ModuleBay.objects.create(device=self.device, name=name) + if bay_types: + bay.module_bay_types.set(bay_types) + return bay + + def _install(self, bay, module_type): + return Module.objects.create(device=self.device, module_bay=bay, module_type=module_type) + + # --- Module.clean() validation --- + + def test_clean_blocks_incompatible_install(self): + """Module.clean() raises ValidationError when bay and module type have disjoint type sets.""" + bay = self._make_bay('Bay Compat 1', self.bay_type_b) + module = Module(device=self.device, module_bay=bay, module_type=self.module_type_a) + with self.assertRaises(ValidationError): + module.clean() + + def test_clean_allows_compatible_install(self): + """Module.clean() passes when bay and module type share at least one bay type.""" + bay = self._make_bay('Bay Compat 2', self.bay_type_a) + module = Module(device=self.device, module_bay=bay, module_type=self.module_type_a) + module.clean() # should not raise + + def test_clean_allows_unconstrained_module_type(self): + """Module.clean() passes when the module type has no bay type constraints.""" + bay = self._make_bay('Bay Compat 3', self.bay_type_a) + module = Module(device=self.device, module_bay=bay, module_type=self.module_type_any) + module.clean() # should not raise + + def test_clean_allows_unconstrained_bay(self): + """Module.clean() passes when the bay has no bay type constraints.""" + bay = self._make_bay('Bay Compat 4') + module = Module(device=self.device, module_bay=bay, module_type=self.module_type_a) + module.clean() # should not raise + + # --- Module.is_bay_compatible --- + + def test_is_bay_compatible_false_when_disjoint(self): + """Module.is_bay_compatible returns False when bay and module type sets are disjoint.""" + bay = self._make_bay('Bay Compat 5', self.bay_type_b) + # Bypass clean() to create an incompatible installation for testing the property + module = Module.objects.create(device=self.device, module_bay=bay, module_type=self.module_type_a) + module.refresh_from_db() + self.assertFalse(module.is_bay_compatible) + + def test_is_bay_compatible_true_when_overlapping(self): + """Module.is_bay_compatible returns True when bay and module type share a bay type.""" + bay = self._make_bay('Bay Compat 6', self.bay_type_a) + module = self._install(bay, self.module_type_a) + self.assertTrue(module.is_bay_compatible) + + def test_is_bay_compatible_true_when_module_type_unconstrained(self): + """Module.is_bay_compatible returns True when module type has no constraints.""" + bay = self._make_bay('Bay Compat 7', self.bay_type_a) + module = self._install(bay, self.module_type_any) + self.assertTrue(module.is_bay_compatible) + + def test_is_bay_compatible_true_when_bay_unconstrained(self): + """Module.is_bay_compatible returns True when bay has no constraints.""" + bay = self._make_bay('Bay Compat 8') + module = self._install(bay, self.module_type_a) + self.assertTrue(module.is_bay_compatible) + + # --- ModuleType.get_incompatible_modules --- + + def test_get_incompatible_modules_returns_incompatible(self): + """ModuleType.get_incompatible_modules includes modules in bays with disjoint type sets.""" + bay = self._make_bay('Bay Compat 9', self.bay_type_b) + module = Module.objects.create(device=self.device, module_bay=bay, module_type=self.module_type_a) + qs = self.module_type_a.get_incompatible_modules() + self.assertIn(module, qs) + + def test_get_incompatible_modules_excludes_compatible(self): + """ModuleType.get_incompatible_modules excludes modules in bays with matching type sets.""" + bay = self._make_bay('Bay Compat 10', self.bay_type_a) + module = self._install(bay, self.module_type_a) + qs = self.module_type_a.get_incompatible_modules() + self.assertNotIn(module, qs) + + def test_get_incompatible_modules_excludes_unconstrained_bay(self): + """ModuleType.get_incompatible_modules excludes modules in unconstrained bays.""" + bay = self._make_bay('Bay Compat 11') + module = self._install(bay, self.module_type_a) + qs = self.module_type_a.get_incompatible_modules() + self.assertNotIn(module, qs) + + def test_get_incompatible_modules_empty_when_type_unconstrained(self): + """ModuleType.get_incompatible_modules returns empty queryset when type has no constraints.""" + bay = self._make_bay('Bay Compat 12', self.bay_type_a) + self._install(bay, self.module_type_any) + qs = self.module_type_any.get_incompatible_modules() + self.assertFalse(qs.exists()) + + # --- ModuleBay.is_module_compatible --- + + def test_bay_is_module_compatible_false_when_disjoint(self): + """ModuleBay.is_module_compatible returns False when bay and installed module sets are disjoint.""" + bay = self._make_bay('Bay Compat 13', self.bay_type_b) + Module.objects.create(device=self.device, module_bay=bay, module_type=self.module_type_a) + bay.refresh_from_db() + self.assertFalse(bay.is_module_compatible) + + def test_bay_is_module_compatible_true_when_overlapping(self): + """ModuleBay.is_module_compatible returns True when sets overlap.""" + bay = self._make_bay('Bay Compat 14', self.bay_type_a) + self._install(bay, self.module_type_a) + bay.refresh_from_db() + self.assertTrue(bay.is_module_compatible) + + def test_bay_is_module_compatible_true_when_no_module(self): + """ModuleBay.is_module_compatible returns True when nothing is installed.""" + bay = self._make_bay('Bay Compat 15', self.bay_type_a) + self.assertTrue(bay.is_module_compatible) + + def test_bay_is_module_compatible_true_when_bay_unconstrained(self): + """ModuleBay.is_module_compatible returns True when bay has no constraints.""" + bay = self._make_bay('Bay Compat 16') + Module.objects.create(device=self.device, module_bay=bay, module_type=self.module_type_a) + bay.refresh_from_db() + self.assertTrue(bay.is_module_compatible) + + class CableTestCase(TestCase): @classmethod @@ -2189,6 +2469,97 @@ class CableTestCase(TestCase): self.assertEqual(a_terms, [interface1]) self.assertEqual(b_terms, [interface2]) + def _create_multiposition_cable(self, count=4): + """ + Create a cable with `count` terminations at either end, using the 4C1P trunk profile. Returns + the cable and its A & B terminating objects. + """ + device1 = Device.objects.get(name='TestDevice1') + device2 = Device.objects.get(name='TestDevice2') + a_interfaces = [ + Interface.objects.create(device=device1, name=f'trunk-a{i}') for i in range(count) + ] + b_interfaces = [ + Interface.objects.create(device=device2, name=f'trunk-b{i}') for i in range(count) + ] + cable = Cable( + a_terminations=a_interfaces, + b_terminations=b_interfaces, + profile=CableProfileChoices.TRUNK_4C1P, + ) + cable.save() + + return cable, a_interfaces, b_interfaces + + def _get_connectors(self, cable, cable_end): + return [ + (ct.connector, ct.termination) for ct in cable.terminations.filter(cable_end=cable_end) + ] + + def test_reordering_terminations_reassigns_connectors(self): + """ + A Cable's terminations are assigned to connectors in the order given, so reordering them must + rewire the Cable even though its set of terminating objects is unchanged. + """ + cable, a_interfaces, b_interfaces = self._create_multiposition_cable() + self.assertEqual( + self._get_connectors(cable, 'B'), list(enumerate(b_interfaces, start=1)) + ) + + # Reverse the B side terminations + cable = Cable.objects.get(pk=cable.pk) + cable.b_terminations = list(reversed(b_interfaces)) + cable.save() + self.assertEqual( + self._get_connectors(cable, 'B'), list(enumerate(reversed(b_interfaces), start=1)) + ) + + # The A side, which was not modified, must be left alone + self.assertEqual( + self._get_connectors(cable, 'A'), list(enumerate(a_interfaces, start=1)) + ) + + # The reordering must be reflected in the terminations' link peers + self.assertEqual( + Interface.objects.get(pk=a_interfaces[0].pk).link_peers, [b_interfaces[-1]] + ) + + def test_removing_a_termination_reassigns_connectors(self): + """ + Removing a termination from the middle of a Cable's list must renumber the connectors of those + which follow it. + """ + cable, a_interfaces, b_interfaces = self._create_multiposition_cable() + + cable = Cable.objects.get(pk=cable.pk) + cable.b_terminations = [b_interfaces[0], b_interfaces[2], b_interfaces[3]] + cable.save() + self.assertEqual( + self._get_connectors(cable, 'B'), + [(1, b_interfaces[0]), (2, b_interfaces[2]), (3, b_interfaces[3])] + ) + + def test_appending_a_termination_preserves_connectors(self): + """ + Appending a termination must not disturb the connectors already assigned to the terminations + which precede it. + """ + cable, a_interfaces, b_interfaces = self._create_multiposition_cable(count=3) + original_cts = {ct.termination: ct.pk for ct in cable.terminations.filter(cable_end='B')} + new_interface = Interface.objects.create(device=Device.objects.get(name='TestDevice2'), name='trunk-b3') + + cable = Cable.objects.get(pk=cable.pk) + cable.b_terminations = [*b_interfaces, new_interface] + cable.save() + self.assertEqual( + self._get_connectors(cable, 'B'), list(enumerate([*b_interfaces, new_interface], start=1)) + ) + + # The existing CableTerminations must not have been recreated + for ct in cable.terminations.filter(cable_end='B'): + if ct.termination in original_cts: + self.assertEqual(ct.pk, original_cts[ct.termination]) + @tag('regression') # #21498 def test_path_refreshes_replaced_cablepath_reference(self): """ @@ -2954,122 +3325,550 @@ class PowerPortDrawTestCase(TestCase): self.assertEqual(legs_by_name['C']['allocated'], 0) -class ComponentInstantiationConnectionTestCase(TestCase): +class InventoryItemCycleTestCase(TestCase): """ - Verify that component instantiation issues its queries against the connection the - parent object was written to, rather than letting DATABASE_ROUTERS select one. On an - installation with routers configured (e.g. netbox_branching), a routed query reads or - writes the component in the wrong database. - - Where a path instantiates components, PinnedConnectionRouter cannot be used: Django's - own forward-relation descriptor consults the router when a related object is assigned - to an unsaved instance. Those paths are checked by capturing the alias handed to the - call instead. + InventoryItem (ltree-backed, not the nested-group base) must reject assigning + self or a descendant as parent — behavior django-mptt previously enforced via + InvalidMove on save(). """ + @classmethod + def setUpTestData(cls): + site = Site.objects.create(name='Site 1', slug='inv-site-1') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='inv-mfr-1') + device_type = DeviceType.objects.create( + manufacturer=manufacturer, model='Device Type 1', slug='inv-dt-1' + ) + role = DeviceRole.objects.create(name='Role 1', slug='inv-role-1') + cls.device = Device.objects.create( + name='Device 1', device_type=device_type, role=role, site=site + ) + + def test_cannot_assign_descendant_as_parent(self): + a = InventoryItem.objects.create(device=self.device, name='A') + b = InventoryItem.objects.create(device=self.device, name='B', parent=a) + c = InventoryItem.objects.create(device=self.device, name='C', parent=b) + a.parent = c + with self.assertRaises(ValidationError): + a.full_clean() + # The save()-level guard also rejects the cycle when clean() is bypassed. + with self.assertRaises(ValidationError): + a.save() + + def test_cannot_assign_self_as_parent(self): + a = InventoryItem.objects.create(device=self.device, name='A') + a.parent = a + with self.assertRaises(ValidationError): + a.full_clean() + + +class InventoryItemTemplateCycleTestCase(TestCase): + """InventoryItemTemplate must likewise reject self/descendant as parent.""" + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='iit-mfr-1') + cls.device_type = DeviceType.objects.create( + manufacturer=manufacturer, model='Device Type 1', slug='iit-dt-1' + ) + + def test_cannot_assign_descendant_as_parent(self): + a = InventoryItemTemplate.objects.create(device_type=self.device_type, name='A') + b = InventoryItemTemplate.objects.create(device_type=self.device_type, name='B', parent=a) + a.parent = b + with self.assertRaises(ValidationError): + a.full_clean() + with self.assertRaises(ValidationError): + a.save() + + def test_cannot_assign_self_as_parent(self): + a = InventoryItemTemplate.objects.create(device_type=self.device_type, name='A') + a.parent = a + with self.assertRaises(ValidationError): + a.full_clean() + + +class CoolingComponentTestCase(TestCase): + @classmethod def setUpTestData(cls): cls.site = Site.objects.create(name='Site 1', slug='site-1') - manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - cls.device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1') - cls.device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') - cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') + cls.manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + cls.role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') - def _record_module_bay_save_aliases(self): + def test_cooling_method_inherited_from_device_type(self): """ - Patch ModuleBay.save() to record the database alias passed to each call. - """ - aliases = [] - original_save = ModuleBay.save - - def record_alias(instance, *args, **kwargs): - aliases.append(kwargs.get('using')) - return original_save(instance, *args, **kwargs) - - return aliases, patch.object(ModuleBay, 'save', record_alias) - - def test_module_bay_tree_id_lookup_pinned_to_saving_connection(self): - """ - Inserting a root ModuleBay looks up the highest existing tree ID, which must be - read from the connection the bay is being written to. + A new Device should inherit its cooling_method from the DeviceType when not explicitly set. """ + device_type = DeviceType.objects.create( + manufacturer=self.manufacturer, + model='Device Type 1', + slug='device-type-1', + cooling_method=CoolingMethodChoices.METHOD_LIQUID + ) device = Device.objects.create( - name='Device 1', device_type=self.device_type, role=self.device_role, site=self.site + site=self.site, + device_type=device_type, + role=self.role, + name='Device 1' ) - # Instantiate outside the router, as assigning the Device consults it. - module_bay = ModuleBay(device=device, name='Module Bay 1') + self.assertEqual(device.cooling_method, CoolingMethodChoices.METHOD_LIQUID) - with override_settings(DATABASE_ROUTERS=[PinnedConnectionRouter(ModuleBay)]): - module_bay.save(using='default') - - self.assertTrue(ModuleBay.objects.filter(pk=module_bay.pk).exists()) - - def test_device_module_bays_receive_saving_connection(self): + def test_cooling_method_not_overridden_when_set(self): """ - ModuleBays are instantiated individually (rather than in bulk) to maintain the MPTT - tree, so each save() must be given the Device's connection. + A new Device with an explicitly-set cooling_method should not be overridden by the DeviceType. """ - ModuleBayTemplate.objects.create(device_type=self.device_type, name='Module Bay 1') - - device = Device( - name='Device 1', device_type=self.device_type, role=self.device_role, site=self.site + device_type = DeviceType.objects.create( + manufacturer=self.manufacturer, + model='Device Type 2', + slug='device-type-2', + cooling_method=CoolingMethodChoices.METHOD_LIQUID ) - aliases, spy = self._record_module_bay_save_aliases() - with spy: - device.save() + device = Device.objects.create( + site=self.site, + device_type=device_type, + role=self.role, + name='Device 2', + cooling_method=CoolingMethodChoices.METHOD_AIR + ) + self.assertEqual(device.cooling_method, CoolingMethodChoices.METHOD_AIR) - self.assertEqual(aliases, [device._state.db]) - self.assertEqual(ModuleBay.objects.filter(device=device).count(), 1) + def test_device_creation_instantiates_cooling_components(self): + """ + Creating a Device from a DeviceType with cooling component templates should auto-instantiate + matching CoolingIntake and CoolingOutflow components. + """ + device_type = DeviceType.objects.create( + manufacturer=self.manufacturer, + model='Device Type 3', + slug='device-type-3' + ) - def test_module_module_bays_receive_saving_connection(self): - """ - Replicated MPTT components are likewise saved individually, and must be given the - Module's connection. - """ - ModuleBayTemplate.objects.create(module_type=self.module_type, name='Module Bay 1') + cooling_intake_template = CoolingIntakeTemplate.objects.create( + device_type=device_type, + name='Cooling Port 1', + type=CoolingConnectorTypeChoices.TYPE_UQD, + diameter=Decimal('25'), + diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER, + max_flow=100, + max_flow_unit=FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE + ) + CoolingOutflowTemplate.objects.create( + device_type=device_type, + name='Cooling Outlet 1', + type=CoolingConnectorTypeChoices.TYPE_UQD, + diameter=Decimal('25'), + diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER + ) device = Device.objects.create( - name='Device 1', device_type=self.device_type, role=self.device_role, site=self.site + site=self.site, + device_type=device_type, + role=self.role, + name='Device 3' ) - parent_bay = ModuleBay.objects.create(device=device, name='Parent Bay') - module = Module(device=device, module_bay=parent_bay, module_type=self.module_type) - aliases, spy = self._record_module_bay_save_aliases() - with spy: - module.save() + cooling_intake = CoolingIntake.objects.get( + device=device, + name='Cooling Port 1', + type=CoolingConnectorTypeChoices.TYPE_UQD, + diameter=Decimal('25'), + diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER, + max_flow=100, + max_flow_unit=FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE + ) + self.assertEqual(cooling_intake_template.max_flow, cooling_intake.max_flow) - self.assertEqual(aliases, [module._state.db]) - self.assertEqual(ModuleBay.objects.filter(module=module).count(), 1) + CoolingOutflow.objects.get( + device=device, + name='Cooling Outlet 1', + type=CoolingConnectorTypeChoices.TYPE_UQD, + diameter=Decimal('25'), + diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER + ) - def test_module_component_rebuild_uses_saving_connection(self): + def test_cooling_choice_colors_resolve(self): """ - Adopting existing components assigns them to the Module via bulk_update(), which - bypasses save() and so requires an explicit MPTT tree rebuild. That rebuild must - run on the Module's connection. + ChoiceFieldColumn and ChoiceAttr both render a badge color by calling get_FOO_color() on the + instance, so every model exposing a colored cooling choice must implement the accessor. """ - ModuleBayTemplate.objects.create(module_type=self.module_type, name='Module Bay 1') + for model in (Device, DeviceType, ModuleType): + with self.subTest(model=model.__name__): + instance = model(cooling_method=CoolingMethodChoices.METHOD_LIQUID) + self.assertEqual( + instance.get_cooling_method_color(), + CoolingMethodChoices.colors[CoolingMethodChoices.METHOD_LIQUID] + ) + # An unset value has no color, which the consumers fall back on + self.assertIsNone(model().get_cooling_method_color()) + + for model in (Rack, RackType): + with self.subTest(model=model.__name__): + instance = model(cooling_capability=RackCoolingCapabilityChoices.CAPABILITY_HYBRID) + self.assertEqual( + instance.get_cooling_capability_color(), + RackCoolingCapabilityChoices.colors[RackCoolingCapabilityChoices.CAPABILITY_HYBRID] + ) + self.assertIsNone(model().get_cooling_capability_color()) + + def test_measurements_below_minimum_rejected(self): + """ + A populated diameter or flow rate must be positive and non-zero: anything below the smallest storable + value (0.01) should raise a ValidationError (via MinValueValidator) rather than a raw ValueError from + the unit conversion in save(). + """ + device_type = DeviceType.objects.create( + manufacturer=self.manufacturer, model='Device Type 8', slug='device-type-8' + ) + device = Device.objects.create( + site=self.site, device_type=device_type, role=self.role, name='Device F' + ) + cooling_source = CoolingSource.objects.create( + site=self.site, name='Cooling Source F', type=CoolingSourceTypeChoices.TYPE_CHILLER + ) + + for value in (Decimal('-5'), Decimal('0')): + with self.subTest(diameter=value): + cooling_intake = CoolingIntake( + device=device, + name='Cooling Port 1', + diameter=value, + diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER, + ) + with self.assertRaises(ValidationError): + cooling_intake.full_clean() + + with self.subTest(max_flow=value): + cooling_intake = CoolingIntake( + device=device, + name='Cooling Port 1', + max_flow=value, + max_flow_unit=FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE, + ) + with self.assertRaises(ValidationError): + cooling_intake.full_clean() + + with self.subTest(feed_max_flow=value): + cooling_feed = CoolingFeed( + cooling_source=cooling_source, + name='Cooling Feed F', + max_flow=value, + max_flow_unit=FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE, + ) + with self.assertRaises(ValidationError): + cooling_feed.full_clean() + + # The minimum itself is permitted + CoolingIntake( + device=device, + name='Cooling Port 1', + diameter=Decimal('0.01'), + diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER, + max_flow=Decimal('0.01'), + max_flow_unit=FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE, + ).full_clean() + + # Sub-unit values are permitted: fractional-inch fittings (e.g. 1/2" NPT) and cold plates rated below + # one gallon per minute are both commonplace. + CoolingIntake( + device=device, + name='Cooling Port 1', + diameter=Decimal('0.5'), + diameter_unit=DiameterUnitChoices.UNIT_INCH, + max_flow=Decimal('0.75'), + max_flow_unit=FlowRateUnitChoices.UNIT_GALLONS_PER_MINUTE, + ).full_clean() + + CoolingFeed( + cooling_source=cooling_source, + name='Cooling Feed F', + max_flow=Decimal('0.5'), + max_flow_unit=FlowRateUnitChoices.UNIT_GALLONS_PER_MINUTE, + ).full_clean() + + def test_parent_intake_resolved_on_device_instantiation(self): + """ + A CoolingOutflowTemplate with a parent CoolingIntakeTemplate should resolve to the newly created + CoolingIntake on the same device. This depends on intakes being instantiated before outflows. + """ + device_type = DeviceType.objects.create( + manufacturer=self.manufacturer, model='Device Type 12', slug='device-type-12' + ) + cooling_intake_template = CoolingIntakeTemplate.objects.create( + device_type=device_type, name='Cooling Port 1' + ) + CoolingOutflowTemplate.objects.create( + device_type=device_type, name='Cooling Outlet 1', cooling_intake=cooling_intake_template + ) + # A second outflow with no parent must remain unassigned + CoolingOutflowTemplate.objects.create(device_type=device_type, name='Cooling Outlet 2') device = Device.objects.create( - name='Device 1', device_type=self.device_type, role=self.device_role, site=self.site + site=self.site, device_type=device_type, role=self.role, name='Device J' ) - parent_bay = ModuleBay.objects.create(device=device, name='Parent Bay') - child_bay = ModuleBay.objects.create(device=device, name='Module Bay 1') - aliases = [] - manager_class = type(ModuleBay.objects) - original_rebuild = manager_class.rebuild + cooling_intake = CoolingIntake.objects.get(device=device, name='Cooling Port 1') + self.assertEqual( + CoolingOutflow.objects.get(device=device, name='Cooling Outlet 1').cooling_intake, + cooling_intake + ) + self.assertIsNone(CoolingOutflow.objects.get(device=device, name='Cooling Outlet 2').cooling_intake) - def record_alias(manager, *args, **kwargs): - # Manager.db falls back to the router, so the private attribute is the only - # indication of whether an alias was set explicitly. - aliases.append(manager._db) - return original_rebuild(manager, *args, **kwargs) + def test_parent_intake_resolved_on_module_instantiation(self): + """ + The parent intake of a CoolingOutflowTemplate should likewise resolve when the components are + instantiated for a Module rather than a Device. + """ + device_type = DeviceType.objects.create( + manufacturer=self.manufacturer, model='Device Type 13', slug='device-type-13' + ) + device = Device.objects.create( + site=self.site, device_type=device_type, role=self.role, name='Device K' + ) + module_bay = ModuleBay.objects.create(device=device, name='Module Bay 1') - module = Module(device=device, module_bay=parent_bay, module_type=self.module_type) - module._adopt_components = True - module._disable_replication = True - with patch.object(manager_class, 'rebuild', record_alias): - module.save() + module_type = ModuleType.objects.create(manufacturer=self.manufacturer, model='Module Type 1') + cooling_intake_template = CoolingIntakeTemplate.objects.create( + module_type=module_type, name='Cooling Port 1' + ) + CoolingOutflowTemplate.objects.create( + module_type=module_type, name='Cooling Outlet 1', cooling_intake=cooling_intake_template + ) - child_bay.refresh_from_db() - self.assertEqual(child_bay.module, module) - self.assertEqual(aliases, [module._state.db]) + module = Module.objects.create(device=device, module_bay=module_bay, module_type=module_type) + + cooling_intake = CoolingIntake.objects.get(module=module, name='Cooling Port 1') + cooling_outflow = CoolingOutflow.objects.get(module=module, name='Cooling Outlet 1') + self.assertEqual(cooling_outflow.cooling_intake, cooling_intake) + self.assertEqual(cooling_outflow.device, device) + + def test_measurements_normalized_on_save(self): + """ + Saving a component should populate the normalized _abs_* columns, converting from the selected + unit to the canonical unit (millimeters for diameter, liters per minute for flow). + """ + device_type = DeviceType.objects.create( + manufacturer=self.manufacturer, model='Device Type 9', slug='device-type-9' + ) + device = Device.objects.create( + site=self.site, device_type=device_type, role=self.role, name='Device G' + ) + cooling_intake = CoolingIntake.objects.create( + device=device, + name='Cooling Port 1', + diameter=Decimal('1'), + diameter_unit=DiameterUnitChoices.UNIT_INCH, + max_flow=Decimal('10'), + max_flow_unit=FlowRateUnitChoices.UNIT_GALLONS_PER_MINUTE, + ) + cooling_intake.refresh_from_db() + self.assertEqual(cooling_intake._abs_diameter, Decimal('25.4')) + self.assertEqual(cooling_intake._abs_max_flow, Decimal('37.8541')) + # The public aliases used by templates should mirror the underscore-prefixed columns + self.assertEqual(cooling_intake.abs_diameter, cooling_intake._abs_diameter) + self.assertEqual(cooling_intake.abs_max_flow, cooling_intake._abs_max_flow) + + # Clearing a value should null both its unit and its normalized column + cooling_intake.diameter = None + cooling_intake.max_flow = None + cooling_intake.save() + cooling_intake.refresh_from_db() + self.assertIsNone(cooling_intake.diameter_unit) + self.assertIsNone(cooling_intake._abs_diameter) + self.assertIsNone(cooling_intake.max_flow_unit) + self.assertIsNone(cooling_intake._abs_max_flow) + + def test_measurements_normalized_on_component_instantiation(self): + """ + Components instantiated from templates are written via bulk_create, which bypasses save(); the + normalized _abs_* columns must still be populated. + """ + device_type = DeviceType.objects.create( + manufacturer=self.manufacturer, model='Device Type 10', slug='device-type-10' + ) + CoolingIntakeTemplate.objects.create( + device_type=device_type, + name='Cooling Port 1', + diameter=Decimal('1'), + diameter_unit=DiameterUnitChoices.UNIT_INCH, + max_flow=Decimal('6'), + max_flow_unit=FlowRateUnitChoices.UNIT_CUBIC_METERS_PER_HOUR, + ) + CoolingOutflowTemplate.objects.create( + device_type=device_type, + name='Cooling Outlet 1', + diameter=Decimal('2.5'), + diameter_unit=DiameterUnitChoices.UNIT_CENTIMETER, + ) + + device = Device.objects.create( + site=self.site, device_type=device_type, role=self.role, name='Device H' + ) + + cooling_intake = CoolingIntake.objects.get(device=device, name='Cooling Port 1') + self.assertEqual(cooling_intake._abs_diameter, Decimal('25.4')) + self.assertEqual(cooling_intake._abs_max_flow, Decimal('100')) + + cooling_outflow = CoolingOutflow.objects.get(device=device, name='Cooling Outlet 1') + self.assertEqual(cooling_outflow._abs_diameter, Decimal('25')) + + def test_measurement_unit_required(self): + """ + Setting a measurement without its accompanying unit should raise a ValidationError. + """ + device_type = DeviceType.objects.create( + manufacturer=self.manufacturer, model='Device Type 11', slug='device-type-11' + ) + device = Device.objects.create( + site=self.site, device_type=device_type, role=self.role, name='Device I' + ) + + with self.assertRaises(ValidationError): + CoolingIntake(device=device, name='Cooling Port 1', diameter=Decimal('25')).full_clean() + + with self.assertRaises(ValidationError): + CoolingIntake(device=device, name='Cooling Port 2', max_flow=Decimal('100')).full_clean() + + with self.assertRaises(ValidationError): + CoolingOutflow(device=device, name='Cooling Outlet 1', diameter=Decimal('25')).full_clean() + + def test_cooling_feed_flow_normalized(self): + """ + CoolingFeed shares the flow-rate normalization applied to device components, and likewise requires + a unit whenever a flow rate is set. + """ + cooling_source = CoolingSource.objects.create( + site=self.site, + name='Cooling Source 1', + type=CoolingSourceTypeChoices.TYPE_CHILLER, + ) + cooling_feed = CoolingFeed.objects.create( + cooling_source=cooling_source, + name='Cooling Feed 1', + max_flow=Decimal('6'), + max_flow_unit=FlowRateUnitChoices.UNIT_CUBIC_METERS_PER_HOUR, + ) + cooling_feed.refresh_from_db() + self.assertEqual(cooling_feed._abs_max_flow, Decimal('100')) + self.assertEqual(cooling_feed.abs_max_flow, cooling_feed._abs_max_flow) + + with self.assertRaises(ValidationError): + CoolingFeed( + cooling_source=cooling_source, name='Cooling Feed 2', max_flow=Decimal('100') + ).full_clean() + + def test_cooling_outflow_clean_different_device(self): + """ + CoolingOutflow.clean() should raise a ValidationError when its cooling_intake belongs to a + different device. + """ + device_type = DeviceType.objects.create( + manufacturer=self.manufacturer, + model='Device Type 4', + slug='device-type-4' + ) + device1 = Device.objects.create( + site=self.site, device_type=device_type, role=self.role, name='Device A' + ) + device2 = Device.objects.create( + site=self.site, device_type=device_type, role=self.role, name='Device B' + ) + + cooling_intake = CoolingIntake.objects.create(device=device1, name='Cooling Port 1') + cooling_outflow = CoolingOutflow(device=device2, name='Cooling Outlet 1', cooling_intake=cooling_intake) + + with self.assertRaises(ValidationError): + cooling_outflow.full_clean() + + def test_cooling_source_location_site_mismatch(self): + """ + CoolingSource.clean() should raise a ValidationError when its location belongs to a different site. + """ + site2 = Site.objects.create(name='Site 2', slug='site-2') + location = Location.objects.create(name='Location 1', slug='location-1', site=site2) + cooling_source = CoolingSource( + site=self.site, + location=location, + name='Cooling Source 1', + type=CoolingSourceTypeChoices.TYPE_CHILLER, + status=CoolingSourceStatusChoices.STATUS_ACTIVE, + ) + with self.assertRaises(ValidationError): + cooling_source.full_clean() + + def test_cooling_feed_rack_site_mismatch(self): + """ + CoolingFeed.clean() should raise a ValidationError when its rack is in a different site than the + cooling source. + """ + site2 = Site.objects.create(name='Site 3', slug='site-3') + cooling_source = CoolingSource.objects.create( + site=self.site, + name='Cooling Source 3', + type=CoolingSourceTypeChoices.TYPE_CHILLER, + status=CoolingSourceStatusChoices.STATUS_ACTIVE, + ) + rack = Rack.objects.create(name='Rack 1', site=site2, status=RackStatusChoices.STATUS_ACTIVE) + cooling_feed = CoolingFeed( + cooling_source=cooling_source, + rack=rack, + name='Cooling Feed 1', + status=CoolingFeedStatusChoices.STATUS_ACTIVE, + ) + with self.assertRaises(ValidationError): + cooling_feed.full_clean() + + def test_cooling_chain_valid(self): + """ + A non-looping intake/outflow assignment should pass validation. + """ + device_type = DeviceType.objects.create( + manufacturer=self.manufacturer, model='Device Type 5', slug='device-type-5' + ) + device = Device.objects.create( + site=self.site, device_type=device_type, role=self.role, name='Device C' + ) + cooling_outflow = CoolingOutflow.objects.create(device=device, name='Cooling Outlet 1') + cooling_intake = CoolingIntake(device=device, name='Cooling Port 1', cooling_outflow=cooling_outflow) + + # Should not raise + cooling_intake.full_clean() + + def test_cooling_intake_loop_rejected(self): + """ + Closing the intake/outflow chain into a loop from the intake side should raise a ValidationError. + """ + device_type = DeviceType.objects.create( + manufacturer=self.manufacturer, model='Device Type 6', slug='device-type-6' + ) + device = Device.objects.create( + site=self.site, device_type=device_type, role=self.role, name='Device D' + ) + cooling_intake = CoolingIntake.objects.create(device=device, name='Cooling Port 1') + cooling_outflow = CoolingOutflow.objects.create( + device=device, name='Cooling Outlet 1', cooling_intake=cooling_intake + ) + + # The outflow is fed by the intake; supplying that same intake from the outflow closes the loop + cooling_intake.cooling_outflow = cooling_outflow + with self.assertRaises(ValidationError): + cooling_intake.full_clean() + + def test_cooling_outflow_loop_rejected(self): + """ + Closing the intake/outflow chain into a loop from the outflow side should raise a ValidationError. + """ + device_type = DeviceType.objects.create( + manufacturer=self.manufacturer, model='Device Type 7', slug='device-type-7' + ) + device = Device.objects.create( + site=self.site, device_type=device_type, role=self.role, name='Device E' + ) + cooling_outflow = CoolingOutflow.objects.create(device=device, name='Cooling Outlet 1') + cooling_intake = CoolingIntake.objects.create( + device=device, name='Cooling Port 1', cooling_outflow=cooling_outflow + ) + + # The intake is supplied by the outflow; feeding that same intake into the outflow closes the loop + cooling_outflow.cooling_intake = cooling_intake + with self.assertRaises(ValidationError): + cooling_outflow.full_clean() diff --git a/netbox/dcim/tests/test_module_moves.py b/netbox/dcim/tests/test_module_moves.py new file mode 100644 index 000000000..75de57b58 --- /dev/null +++ b/netbox/dcim/tests/test_module_moves.py @@ -0,0 +1,1964 @@ +import re +import signal +import uuid +from contextlib import contextmanager +from unittest.mock import patch + +from django.apps import apps +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.db import IntegrityError, OperationalError, connection, router, transaction +from django.db.models import QuerySet +from django.test import RequestFactory, TestCase, override_settings +from django.test.utils import CaptureQueriesContext + +from circuits.models import Provider, ProviderNetwork, VirtualCircuit, VirtualCircuitTermination, VirtualCircuitType +from core.models import ObjectChange +from dcim.choices import InterfaceModeChoices, InterfaceTypeChoices, ModuleStatusChoices, PortTypeChoices +from dcim.models import ( + Cable, + ConsolePortTemplate, + ConsoleServerPortTemplate, + CoolingIntake, + CoolingIntakeTemplate, + CoolingOutflow, + CoolingOutflowTemplate, + Device, + DeviceBay, + DeviceRole, + DeviceType, + FrontPort, + FrontPortTemplate, + Interface, + InterfaceTemplate, + InventoryItem, + MACAddress, + Manufacturer, + Module, + ModuleBay, + ModuleBayTemplate, + ModuleType, + PortMapping, + PortTemplateMapping, + PowerOutlet, + PowerOutletTemplate, + PowerPort, + PowerPortTemplate, + RearPort, + RearPortTemplate, + Site, + VirtualDeviceContext, +) +from dcim.models.device_components import ModularComponentModel +from dcim.models.module_moves import COMPONENT_TEMPLATE_ATTRS, ModuleMovePlan +from dcim.utils import get_module_bay_positions, resolve_module_placeholder +from ipam.choices import FHRPGroupProtocolChoices +from ipam.models import VLAN, VRF, FHRPGroup, FHRPGroupAssignment, IPAddress, VLANTranslationPolicy +from netbox.context_managers import event_tracking +from users.models import User +from utilities.exceptions import AbortRequest +from utilities.ordering import naturalize_interface +from utilities.testing import create_test_device +from vpn.choices import L2VPNTypeChoices, TunnelEncapsulationChoices +from vpn.models import L2VPN, L2VPNTermination, Tunnel, TunnelTermination +from wireless.models import WirelessLAN, WirelessLink + + +@contextmanager +def fail_after(seconds): + """ + Fail the enclosed block if it runs longer than the given number of seconds. Backstop + for the cycle-guard tests: a regressed hang fails one test fast instead of stalling the run. + """ + def on_alarm(signum, frame): + raise AssertionError(f'Operation did not complete within {seconds} seconds.') + + previous_handler = signal.signal(signal.SIGALRM, on_alarm) + signal.alarm(seconds) + try: + yield + finally: + signal.alarm(0) + signal.signal(signal.SIGALRM, previous_handler) + + +class ModuleMoveValidationTestCase(TestCase): + + @classmethod + def setUpTestData(cls): + cls.device_a = create_test_device('Device A') + cls.device_b = create_test_device('Device B') + cls.bays_a = [ + ModuleBay.objects.create(device=cls.device_a, name=f'Bay A{i}') for i in range(1, 4) + ] + cls.bays_b = [ + ModuleBay.objects.create(device=cls.device_b, name=f'Bay B{i}') for i in range(1, 3) + ] + manufacturer = Manufacturer.objects.create(name='Manufacturer M', slug='manufacturer-m') + cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type M') + cls.other_module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type N') + cls.module = Module.objects.create( + device=cls.device_a, module_bay=cls.bays_a[0], module_type=cls.module_type + ) + + def test_create_in_occupied_bay_fails(self): + module = Module(device=self.device_a, module_bay=self.bays_a[0], module_type=self.module_type) + with self.assertRaises(ValidationError) as cm: + module.full_clean() + self.assertIn('module_bay', cm.exception.message_dict) + + def test_move_to_occupied_bay_fails(self): + occupant = Module.objects.create( + device=self.device_a, module_bay=self.bays_a[1], module_type=self.module_type + ) + self.module.module_bay = self.bays_a[1] + with self.assertRaises(ValidationError) as cm: + self.module.full_clean() + self.assertIn('module_bay', cm.exception.message_dict) + self.assertIn(str(occupant), str(cm.exception.message_dict['module_bay'])) + + def test_move_to_own_bay_passes(self): + self.module.serial = 'ABC123' + self.module.full_clean() + + def test_move_to_other_device_bay_without_device_change_fails(self): + # Existing device/bay consistency rule must keep rejecting a half-specified move + self.module.module_bay = self.bays_b[0] + with self.assertRaises(ValidationError): + self.module.full_clean() + + def test_move_with_module_type_change_fails(self): + self.module.module_bay = self.bays_a[1] + self.module.module_type = self.other_module_type + with self.assertRaises(ValidationError) as cm: + self.module.full_clean() + self.assertIn('module_type', cm.exception.message_dict) + + def test_module_type_change_without_move_passes(self): + self.module.module_type = self.other_module_type + self.module.full_clean() + self.module.save() + self.module.refresh_from_db() + self.assertEqual(self.module.module_type, self.other_module_type) + + +class ModuleSameDeviceMoveTestCase(TestCase): + + @classmethod + def setUpTestData(cls): + cls.device = create_test_device('Device A') + cls.bay_a = ModuleBay.objects.create(device=cls.device, name='Bay A', position='A') + cls.bay_b = ModuleBay.objects.create(device=cls.device, name='Bay B', position='B') + manufacturer = Manufacturer.objects.create(name='Manufacturer M', slug='manufacturer-m') + cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type M') + cls.module = Module.objects.create( + device=cls.device, module_bay=cls.bay_a, module_type=cls.module_type + ) + cls.interface = Interface.objects.create( + device=cls.device, module=cls.module, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED + ) + cls.child_bay = ModuleBay.objects.create( + device=cls.device, module=cls.module, name='Child Bay 1' + ) + + def test_move_to_empty_bay_updates_module_bay(self): + self.module.module_bay = self.bay_b + self.module.full_clean() + self.module.save() + self.module.refresh_from_db() + self.assertEqual(self.module.module_bay, self.bay_b) + + def test_move_reparents_direct_child_bays(self): + self.module.module_bay = self.bay_b + self.module.save() + self.child_bay.refresh_from_db() + self.assertEqual(self.child_bay.parent_id, self.bay_b.pk) + self.assertTrue(str(self.child_bay.path).startswith(f'{self.bay_b.path}.')) + + def test_move_keeps_components_untouched_without_templates(self): + self.module.module_bay = self.bay_b + self.module.save() + self.interface.refresh_from_db() + self.assertEqual(self.interface.name, 'eth0') + self.assertEqual(self.interface.device, self.device) + + def test_save_without_move_skips_planner(self): + with patch('dcim.models.modules.ModuleMovePlan.from_module') as mock_plan: + self.module.serial = 'XYZ789' + self.module.save() + mock_plan.assert_not_called() + + def test_save_level_move_with_type_change_raises_abort_request(self): + other_type = ModuleType.objects.create( + manufacturer=self.module_type.manufacturer, model='Module Type N' + ) + self.module.module_bay = self.bay_b + self.module.module_type = other_type + with self.assertRaises(AbortRequest): + self.module.save() + + +class ModuleSaveRoutingTestCase(TestCase): + """Pins save() routing against concurrent, stale, deferred, and preset-pk instances.""" + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Manufacturer M', slug='manufacturer-m') + cls.device = create_test_device('Device A') + cls.device_b = create_test_device('Device B') + cls.bay_a = ModuleBay.objects.create(device=cls.device, name='Bay A') + cls.bay_b = ModuleBay.objects.create(device=cls.device, name='Bay B') + cls.bay_c = ModuleBay.objects.create(device=cls.device_b, name='Bay C') + cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type M') + InterfaceTemplate.objects.create( + module_type=cls.module_type, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED + ) + cls.module = Module.objects.create( + device=cls.device, module_bay=cls.bay_a, module_type=cls.module_type + ) + cls.child_bay = ModuleBay.objects.create(device=cls.device, module=cls.module, name='Child Bay 1') + + def _create_nested_subtree(self): + child_module = Module( + device=self.device, + module_bay=self.child_bay, + module_type=self.module_type, + ) + child_module._disable_replication = True + child_module.save() + root_interface = Interface.objects.get(module=self.module, name='eth0') + child_interface = Interface.objects.create( + device=self.device, + module=child_module, + name='child0', + type=InterfaceTypeChoices.TYPE_1GE_FIXED, + ) + return child_module, root_interface, child_interface + + @contextmanager + def _move_to_other_device_before_locked_save(self): + """Complete a competing move after save() routes the write but before the root lock.""" + original_save_existing = Module._save_existing + moved = False + + def save_existing(instance, *args, **kwargs): + nonlocal moved + if not moved: + moved = True + concurrent = Module.objects.get(pk=self.module.pk) + concurrent.device = self.device_b + concurrent.module_bay = self.bay_c + concurrent.save() + return original_save_existing(instance, *args, **kwargs) + + with patch.object(Module, '_save_existing', autospec=True, side_effect=save_existing): + yield + + self.assertTrue(moved) + + def test_plain_save_uses_locked_placement_for_routing(self): + """A competing move landing before the root lock is resolved by the planner, not clobbered.""" + child_module, root_interface, child_interface = self._create_nested_subtree() + stale = Module.objects.get(pk=self.module.pk) + stale.comments = 'Stale session edit' + + with self._move_to_other_device_before_locked_save(): + stale.save() + + self.module.refresh_from_db() + child_module.refresh_from_db() + self.child_bay.refresh_from_db() + root_interface.refresh_from_db() + child_interface.refresh_from_db() + self.assertEqual((self.module.device_id, self.module.module_bay_id), (self.device.pk, self.bay_a.pk)) + self.assertEqual(child_module.device_id, self.device.pk) + self.assertEqual((self.child_bay.device_id, self.child_bay.parent_id), (self.device.pk, self.bay_a.pk)) + self.assertEqual(root_interface.device_id, self.device.pk) + self.assertEqual(child_interface.device_id, self.device.pk) + self.assertEqual(self.module.comments, 'Stale session edit') + + def test_update_fields_are_checked_against_locked_placement(self): + """update_fields completeness is re-judged against the placement a competing move just committed.""" + child_module, root_interface, child_interface = self._create_nested_subtree() + module = Module.objects.get(pk=self.module.pk) + module.module_bay = self.bay_b + + with self._move_to_other_device_before_locked_save(): + with self.assertRaises(AbortRequest): + module.save(update_fields=['module_bay']) + + self.module.refresh_from_db() + child_module.refresh_from_db() + self.child_bay.refresh_from_db() + root_interface.refresh_from_db() + child_interface.refresh_from_db() + self.assertEqual((self.module.device_id, self.module.module_bay_id), (self.device_b.pk, self.bay_c.pk)) + self.assertEqual(child_module.device_id, self.device_b.pk) + self.assertEqual((self.child_bay.device_id, self.child_bay.parent_id), (self.device_b.pk, self.bay_c.pk)) + self.assertEqual(root_interface.device_id, self.device_b.pk) + self.assertEqual(child_interface.device_id, self.device_b.pk) + + def test_stale_instance_plain_save_after_move_keeps_children_consistent(self): + instance_a = Module.objects.get(pk=self.module.pk) + instance_b = Module.objects.get(pk=self.module.pk) + instance_b.module_bay = self.bay_b + instance_b.save() + + instance_a.comments = 'Stale session edit' + instance_a.save() + + self.module.refresh_from_db() + self.child_bay.refresh_from_db() + self.assertEqual(self.module.comments, 'Stale session edit') + self.assertEqual(self.child_bay.parent_id, self.module.module_bay_id) + self.assertTrue(str(self.child_bay.path).startswith(f'{self.module.module_bay.path}.')) + + def test_deferred_device_field_save_with_update_fields_succeeds(self): + module = Module.objects.defer('device').get(pk=self.module.pk) + module.status = ModuleStatusChoices.STATUS_OFFLINE + module.save(update_fields=['status']) + module.refresh_from_db() + self.assertEqual(module.status, ModuleStatusChoices.STATUS_OFFLINE) + self.assertEqual(module.module_bay, self.bay_a) + + def test_out_of_band_placement_change_then_refresh_then_save_skips_planner(self): + # Routing always enters the locked read; a refreshed instance shows no delta there, so no plan is built. + Module.objects.filter(pk=self.module.pk).update(module_bay=self.bay_b) + self.module.refresh_from_db() + with patch.object(ModuleMovePlan, 'from_module') as mock_plan: + self.module.serial = 'REFRESHED1' + self.module.save() + mock_plan.assert_not_called() + self.module.refresh_from_db() + self.assertEqual(self.module.serial, 'REFRESHED1') + self.assertEqual(self.module.module_bay, self.bay_b) + + def test_preset_pk_save_with_unchanged_placement_does_not_replicate_components(self): + interface_count_before = Interface.objects.filter(module=self.module).count() + Module( + pk=self.module.pk, device=self.module.device, module_bay=self.module.module_bay, + module_type=self.module.module_type, + ).save() + self.assertEqual(Interface.objects.filter(module=self.module).count(), interface_count_before) + + def test_preset_unused_pk_save_creates_module(self): + """A never-saved instance with an explicit unused pk is created, not treated as a move.""" + unused_pk = Module.objects.order_by('-pk').first().pk + 1000 + device = create_test_device('Device P') + bay = ModuleBay.objects.create(device=device, name='Preset Bay') + Module(pk=unused_pk, device=device, module_bay=bay, module_type=self.module_type).save() + self.assertTrue(Module.objects.filter(pk=unused_pk).exists()) + self.assertTrue(Interface.objects.filter(module_id=unused_pk, name='eth0').exists()) + + def test_pk_reassignment_clone_creates_new_module(self): + """A fetched instance saved under a fresh unused pk creates a new row and keeps the original.""" + clone = Module.objects.get(pk=self.module.pk) + device = create_test_device('Device C') + bay = ModuleBay.objects.create(device=device, name='Clone Bay') + clone.pk = Module.objects.order_by('-pk').first().pk + 1000 + clone.device = device + clone.module_bay = bay + clone.save() + self.assertTrue(Module.objects.filter(pk=clone.pk).exists()) + self.assertTrue(Module.objects.filter(pk=self.module.pk).exists()) + + def test_plain_save_after_concurrent_delete_recreates_module(self): + """A stale full save whose row was deleted underneath falls through to create, like a plain Django save.""" + stale = Module.objects.get(pk=self.module.pk) + Module.objects.filter(pk=stale.pk).delete() + stale.comments = 'Recreated' + stale.save() + self.assertTrue(Module.objects.filter(pk=stale.pk, comments='Recreated').exists()) + + def test_cross_device_stale_plain_save_moves_subtree_back_consistently(self): + """ + A stale full save that reverts a committed cross-device move runs the planner, + so the root module, child bays, and components land on one device together. + """ + stale = Module.objects.get(pk=self.module.pk) + + mover = Module.objects.get(pk=self.module.pk) + mover.device = self.device_b + mover.module_bay = self.bay_c + mover.save() + + stale.comments = 'Stale full save' + stale.save() + + self.module.refresh_from_db() + self.child_bay.refresh_from_db() + interface = Interface.objects.get(module=self.module, name='eth0') + self.assertEqual(self.module.comments, 'Stale full save') + self.assertEqual(self.module.device, self.device) + self.assertEqual(self.module.module_bay, self.bay_a) + self.assertEqual(self.child_bay.device, self.device) + self.assertEqual(self.child_bay.parent_id, self.bay_a.pk) + self.assertEqual(interface.device, self.device) + + +class ModuleMoveRaceTestCase(TestCase): + + @classmethod + def setUpTestData(cls): + cls.device = create_test_device('Device A') + cls.bay_a = ModuleBay.objects.create(device=cls.device, name='Bay A') + cls.bay_b = ModuleBay.objects.create(device=cls.device, name='Bay B') + cls.bay_c = ModuleBay.objects.create(device=cls.device, name='Bay C') + manufacturer = Manufacturer.objects.create(name='Manufacturer M', slug='manufacturer-m') + cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type M') + cls.module_1 = Module.objects.create( + device=cls.device, module_bay=cls.bay_a, module_type=cls.module_type + ) + cls.module_2 = Module.objects.create( + device=cls.device, module_bay=cls.bay_b, module_type=cls.module_type + ) + + def test_bulk_update_into_occupied_bay_hits_db_constraint(self): + with self.assertRaises(IntegrityError): + with transaction.atomic(): + Module.objects.filter(pk=self.module_2.pk).update(module_bay=self.bay_a) + + def test_locked_validation_catches_occupied_bay_bypassing_clean(self): + # Simulate a TOCTOU window: clean() was never run (direct save), the target + # bay is genuinely occupied, and the in-save locked validation must reject. + self.module_2.module_bay = self.bay_a + with self.assertRaises(AbortRequest): + self.module_2.save() + + def test_move_into_own_subtree_bypassing_clean_is_rejected(self): + child_bay = ModuleBay.objects.create(device=self.device, module=self.module_1, name='Child Bay 1') + self.module_1.module_bay = child_bay + with self.assertRaises(AbortRequest): + self.module_1.save() + + def test_full_clean_on_cyclic_hierarchy_raises_validation_error_promptly(self): + # A two-module cycle created via .update() (bypassing clean()) must raise promptly, not hang. + child_bay_1 = ModuleBay.objects.create(device=self.device, module=self.module_1, name='Child Bay 1') + child_bay_2 = ModuleBay.objects.create(device=self.device, module=self.module_2, name='Child Bay 2') + Module.objects.filter(pk=self.module_1.pk).update(module_bay=child_bay_2) + Module.objects.filter(pk=self.module_2.pk).update(module_bay=child_bay_1) + module = Module.objects.get(pk=self.module_1.pk) + module.module_bay = self.bay_c + with fail_after(15): + with self.assertRaises(ValidationError) as cm: + module.full_clean() + self.assertIn('module_bay', cm.exception.message_dict) + self.assertIn('contains a cycle', str(cm.exception.message_dict['module_bay'])) + + def test_move_into_cyclic_hierarchy_bypassing_clean_raises_abort_request(self): + # Same cycle, reached via direct save() (clean() never runs); must abort, not hang. + child_bay_1 = ModuleBay.objects.create(device=self.device, module=self.module_1, name='Child Bay 1') + child_bay_2 = ModuleBay.objects.create(device=self.device, module=self.module_2, name='Child Bay 2') + Module.objects.filter(pk=self.module_1.pk).update(module_bay=child_bay_2) + Module.objects.filter(pk=self.module_2.pk).update(module_bay=child_bay_1) + self.module_1.module_bay = self.bay_c + with fail_after(15): + with self.assertRaises(AbortRequest): + self.module_1.save() + + def test_create_in_cyclic_hierarchy_with_token_template_raises_abort_request(self): + # _save_new()'s template resolution walks the target bay's ancestry; a cycle must abort, not hang. + child_bay_1 = ModuleBay.objects.create(device=self.device, module=self.module_1, name='Child Bay 1') + child_bay_2 = ModuleBay.objects.create(device=self.device, module=self.module_2, name='Child Bay 2') + Module.objects.filter(pk=self.module_1.pk).update(module_bay=child_bay_2) + Module.objects.filter(pk=self.module_2.pk).update(module_bay=child_bay_1) + token_type = ModuleType.objects.create( + manufacturer=self.module_type.manufacturer, model='Module Type Token' + ) + InterfaceTemplate.objects.create( + module_type=token_type, name='eth{module}/0', type=InterfaceTypeChoices.TYPE_1GE_FIXED + ) + target_bay = ModuleBay.objects.create( + device=self.device, module=self.module_1, name='Child Bay 3', position='3' + ) + # Fresh fetch so the ancestry walk reads database state, not cached relations + target_bay = ModuleBay.objects.get(pk=target_bay.pk) + module = Module(device=self.device, module_bay=target_bay, module_type=token_type) + with fail_after(15): + with self.assertRaises(AbortRequest) as cm: + module.save() + self.assertIn('contains a cycle', cm.exception.message) + + def test_lock_rediscovers_membership_added_after_planning(self): + old = Module.objects.get(pk=self.module_1.pk) + new = Module.objects.get(pk=self.module_1.pk) + new.module_bay = ModuleBay.objects.create(device=self.device, name='Bay C') + plan = ModuleMovePlan.from_module(old_module=old, new_module=new) + late_interface = Interface.objects.create( + device=self.device, module=self.module_1, name='late0', type=InterfaceTypeChoices.TYPE_1GE_FIXED + ) + with transaction.atomic(): + plan.lock() + self.assertIn(late_interface.pk, [obj.pk for obj in plan.components[Interface]]) + + def test_locked_device_get_does_not_exist_raises_abort_request(self): + # Same TOCTOU, at the device row lock acquired by ModuleMovePlan.lock(): the + # single-statement filter(pk__in=...) form returns fewer rows than expected pks. + self.module_1.module_bay = self.bay_c + with patch.object(Device.objects, 'select_for_update') as mock_sfu: + mock_sfu.return_value.filter.return_value.order_by.return_value = [] + with self.assertRaises(AbortRequest): + self.module_1.save() + + +class ModuleMoveSaveContractTestCase(TestCase): + + @classmethod + def setUpTestData(cls): + cls.device = create_test_device('Device A') + cls.bay_a = ModuleBay.objects.create(device=cls.device, name='Bay A') + cls.bay_b = ModuleBay.objects.create(device=cls.device, name='Bay B') + manufacturer = Manufacturer.objects.create(name='Manufacturer M', slug='manufacturer-m') + cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type M') + cls.module = Module.objects.create( + device=cls.device, module_bay=cls.bay_a, module_type=cls.module_type + ) + + def test_update_fields_excluding_module_bay_saves_without_moving(self): + # A changed placement field left out of update_fields is never persisted, so no move happens. + self.module.module_bay = self.bay_b + self.module.serial = 'ABC123' + self.module.save(update_fields=['serial']) + self.module.refresh_from_db() + self.assertEqual(self.module.serial, 'ABC123') + self.assertEqual(self.module.module_bay, self.bay_a) + + def test_update_fields_including_placement_moves(self): + self.module.module_bay = self.bay_b + self.module.save(update_fields=['device', 'module_bay']) + self.module.refresh_from_db() + self.assertEqual(self.module.module_bay, self.bay_b) + + def test_generator_update_fields_excluding_module_bay_saves_without_moving(self): + self.module.module_bay = self.bay_b + self.module.serial = 'ABC123' + self.module.save(update_fields=(field for field in ('serial',))) + self.module.refresh_from_db() + self.assertEqual(self.module.serial, 'ABC123') + self.assertEqual(self.module.module_bay, self.bay_a) + + def test_generator_update_fields_including_placement_moves(self): + self.module.module_bay = self.bay_b + self.module.save(update_fields=(field for field in ('device', 'module_bay'))) + self.module.refresh_from_db() + self.assertEqual(self.module.module_bay, self.bay_b) + + def test_deadlock_is_translated_to_abort_request(self): + deadlock = OperationalError('Simulated deadlock error') + deadlock.__cause__ = type('FakeDeadlock', (Exception,), {'sqlstate': '40P01'})() + self.module.module_bay = self.bay_b + with patch.object(ModuleMovePlan, 'lock', side_effect=deadlock): + with self.assertRaises(AbortRequest): + self.module.save() + + def test_non_deadlock_operational_error_propagates(self): + error = OperationalError('Simulated connection error') + self.module.module_bay = self.bay_b + with patch.object(ModuleMovePlan, 'lock', side_effect=error): + with self.assertRaises(OperationalError): + self.module.save() + + def test_post_save_signals_use_write_alias(self): + ModuleBay.objects.create(device=self.device, module=self.module, name='Child Bay 1') + self.module.module_bay = self.bay_b + with patch('dcim.models.module_moves.post_save') as mock_signal: + self.module.save() + self.assertTrue(mock_signal.send.called) + for call in mock_signal.send.call_args_list: + self.assertEqual(call.kwargs['using'], router.db_for_write(ModuleBay)) + + def test_move_to_deleted_bay_raises_abort_request(self): + target = ModuleBay.objects.create(device=self.device, name='Bay C') + self.module.module_bay = target + ModuleBay.objects.filter(pk=target.pk).delete() + with self.assertRaises(AbortRequest): + self.module.save() + + def test_partial_update_fields_after_completed_move_succeeds(self): + self.module.module_bay = self.bay_b + self.module.save() + self.module.serial = 'A1' + self.module.save(update_fields=['serial']) + self.module.refresh_from_db() + self.assertEqual(self.module.serial, 'A1') + self.assertEqual(self.module.module_bay, self.bay_b) + + def test_update_fields_with_module_bay_name_moves_same_device(self): + child_bay = ModuleBay.objects.create(device=self.device, module=self.module, name='Child Bay 1') + self.module.module_bay = self.bay_b + self.module.save(update_fields=['module_bay']) + self.module.refresh_from_db() + child_bay.refresh_from_db() + self.assertEqual(self.module.module_bay, self.bay_b) + self.assertEqual(child_bay.parent_id, self.bay_b.pk) + + def test_update_fields_with_attnames_moves_cross_device(self): + device_b = create_test_device('Device B') + bay_c = ModuleBay.objects.create(device=device_b, name='Bay C') + self.module.device = device_b + self.module.module_bay = bay_c + self.module.save(update_fields=['device_id', 'module_bay_id']) + self.module.refresh_from_db() + self.assertEqual(self.module.device, device_b) + self.assertEqual(self.module.module_bay, bay_c) + + def test_update_fields_missing_device_rejects_cross_device_delta(self): + device_b = create_test_device('Device B') + bay_c = ModuleBay.objects.create(device=device_b, name='Bay C') + self.module.device = device_b + self.module.module_bay = bay_c + with self.assertRaises(AbortRequest): + self.module.save(update_fields=['module_bay']) + + def test_update_fields_incomplete_against_current_placement_rejects(self): + """ + update_fields completeness is judged against the locked database placement, + not the placement the saving instance last observed. + """ + device_b = create_test_device('Device B') + bay_c = ModuleBay.objects.create(device=device_b, name='Bay C') + stale = Module.objects.get(pk=self.module.pk) + + mover = Module.objects.get(pk=self.module.pk) + mover.device = device_b + mover.module_bay = bay_c + mover.save() + + stale.module_bay = self.bay_b + with self.assertRaises(AbortRequest): + stale.save(update_fields=['module_bay']) + self.module.refresh_from_db() + self.assertEqual(self.module.device, device_b) + self.assertEqual(self.module.module_bay, bay_c) + + def test_update_fields_with_unchanged_placement_field_saves_without_moving(self): + """ + A placement field listed in update_fields with an unchanged value is written as-is; + a diverged placement field left out of update_fields never triggers a move. + """ + self.module.module_bay = self.bay_b + self.module.serial = 'NOMOVE1' + with patch.object(ModuleMovePlan, 'from_module') as mock_plan: + self.module.save(update_fields=['device', 'serial']) + mock_plan.assert_not_called() + self.module.refresh_from_db() + self.assertEqual(self.module.serial, 'NOMOVE1') + self.assertEqual(self.module.module_bay, self.bay_a) + + +class ModuleMoveRenameTestCase(TestCase): + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Manufacturer M', slug='manufacturer-m') + role = DeviceRole.objects.create(name='Role 1', slug='role-1') + site = Site.objects.create(name='Site 1', slug='site-1') + device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Chassis', slug='chassis') + ModuleBayTemplate.objects.create(device_type=device_type, name='Slot 1', position='1') + ModuleBayTemplate.objects.create(device_type=device_type, name='Slot 2', position='2') + + cls.line_card_type = ModuleType.objects.create(manufacturer=manufacturer, model='Line Card') + InterfaceTemplate.objects.create( + module_type=cls.line_card_type, + name='Ethernet{module}/1', + label='Port {module}', + type=InterfaceTypeChoices.TYPE_1GE_FIXED, + ) + ModuleBayTemplate.objects.create( + module_type=cls.line_card_type, name='SFP bay {module}/1', position='{module}/1' + ) + + cls.sfp_type = ModuleType.objects.create(manufacturer=manufacturer, model='SFP') + InterfaceTemplate.objects.create( + module_type=cls.sfp_type, name='SFP {module}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS + ) + + cls.device = Device.objects.create(name='Chassis A', device_type=device_type, role=role, site=site) + cls.slot_1 = cls.device.modulebays.get(name='Slot 1') + cls.slot_2 = cls.device.modulebays.get(name='Slot 2') + cls.line_card = Module.objects.create( + device=cls.device, module_bay=cls.slot_1, module_type=cls.line_card_type + ) + cls.sfp_bay = cls.line_card.modulebays.get(name='SFP bay 1/1') + cls.sfp_module = Module.objects.create( + device=cls.device, module_bay=cls.sfp_bay, module_type=cls.sfp_type + ) + + def _move_line_card_to_slot_2(self): + self.line_card.module_bay = self.slot_2 + self.line_card.full_clean() + self.line_card.save() + + def test_same_device_move_renames_templated_interface(self): + interface = self.line_card.interfaces.get(name='Ethernet1/1') + self._move_line_card_to_slot_2() + interface.refresh_from_db() + self.assertEqual(interface.name, 'Ethernet2/1') + self.assertEqual(interface.label, 'Port 2') + self.assertEqual(interface._name, naturalize_interface('Ethernet2/1', max_length=100)) + + def test_move_renames_nested_bay_and_grandchild_components(self): + sfp_interface = self.sfp_module.interfaces.get(name='SFP 1/1') + self._move_line_card_to_slot_2() + self.sfp_bay.refresh_from_db() + self.assertEqual(self.sfp_bay.name, 'SFP bay 2/1') + self.assertEqual(self.sfp_bay.position, '2/1') + sfp_interface.refresh_from_db() + self.assertEqual(sfp_interface.name, 'SFP 2/1') + + def test_manually_renamed_component_is_preserved(self): + interface = self.line_card.interfaces.get(name='Ethernet1/1') + interface.name = 'uplink-core' + interface.save() + self._move_line_card_to_slot_2() + interface.refresh_from_db() + self.assertEqual(interface.name, 'uplink-core') + + def test_manually_changed_label_is_preserved_while_name_renames(self): + interface = self.line_card.interfaces.get(name='Ethernet1/1') + interface.label = 'Uplink' + interface.save() + self._move_line_card_to_slot_2() + interface.refresh_from_db() + self.assertEqual(interface.name, 'Ethernet2/1') + self.assertEqual(interface.label, 'Uplink') + + def test_ambiguous_template_match_is_preserved(self): + # A second template resolving to the same old name makes the match ambiguous + InterfaceTemplate.objects.create( + module_type=self.line_card_type, name='Ethernet1/1', type=InterfaceTypeChoices.TYPE_1GE_FIXED + ) + interface = self.line_card.interfaces.get(name='Ethernet1/1') + self._move_line_card_to_slot_2() + interface.refresh_from_db() + self.assertEqual(interface.name, 'Ethernet1/1') + + def test_move_after_module_type_change_preserves_names(self): + other_type = ModuleType.objects.create( + manufacturer=self.line_card_type.manufacturer, model='Other Card' + ) + self.line_card.module_type = other_type + self.line_card.save() + interface = self.line_card.interfaces.get(name='Ethernet1/1') + self._move_line_card_to_slot_2() + interface.refresh_from_db() + self.assertEqual(interface.name, 'Ethernet1/1') + + def test_manually_changed_bay_position_stops_rename_cascade(self): + self.sfp_bay.position = 'X' + self.sfp_bay.save() + sfp_interface = self.sfp_module.interfaces.get(name='SFP 1/1') + self._move_line_card_to_slot_2() + self.sfp_bay.refresh_from_db() + self.assertEqual(self.sfp_bay.position, 'X') + sfp_interface.refresh_from_db() + self.assertEqual(sfp_interface.name, 'SFP 1/1') + + def test_duplicate_final_names_within_moved_set_fail(self): + Interface.objects.create( + device=self.device, module=self.line_card, name='Ethernet2/1', + type=InterfaceTypeChoices.TYPE_1GE_FIXED, + ) + self.line_card.module_bay = self.slot_2 + with self.assertRaises(ValidationError): + self.line_card.full_clean() + + def test_conflict_with_existing_destination_component_fails(self): + Interface.objects.create( + device=self.device, name='Ethernet2/1', type=InterfaceTypeChoices.TYPE_1GE_FIXED + ) + self.line_card.module_bay = self.slot_2 + with self.assertRaises(ValidationError): + self.line_card.full_clean() + + def test_rename_chain_collision_is_rejected(self): + # 'E{module}/1' resolves to E1/1 in slot 1 and E2/1 in slot 2, while 'E2/{module}' + # resolves to E2/1 in slot 1 and E2/2 in slot 2: E1/1 -> E2/1 while E2/1 -> E2/2. + # Applying both renames on the same device in one statement is order-dependent. + InterfaceTemplate.objects.create( + module_type=self.line_card_type, name='E2/{module}', type=InterfaceTypeChoices.TYPE_1GE_FIXED + ) + InterfaceTemplate.objects.create( + module_type=self.line_card_type, name='E{module}/1', type=InterfaceTypeChoices.TYPE_1GE_FIXED + ) + Interface.objects.create( + device=self.device, module=self.line_card, name='E2/1', type=InterfaceTypeChoices.TYPE_1GE_FIXED + ) + Interface.objects.create( + device=self.device, module=self.line_card, name='E1/1', type=InterfaceTypeChoices.TYPE_1GE_FIXED + ) + self.line_card.module_bay = self.slot_2 + with self.assertRaises(ValidationError) as cm: + self.line_card.full_clean() + self.assertIn('current name of another moved', str(cm.exception)) + + def test_bay_rename_chain_collision_is_rejected(self): + # 'Bay E2/{module}' resolves to Bay E2/1 in slot 1 and Bay E2/2 in slot 2, while + # 'Bay E{module}/1' resolves to Bay E1/1 in slot 1 and Bay E2/1 in slot 2: Bay E1/1 + # -> Bay E2/1 while Bay E2/1 -> Bay E2/2. Applying both renames in one same-device + # bulk statement would collide on the destination namespace depending on row order. + ModuleBayTemplate.objects.create(module_type=self.line_card_type, name='Bay E2/{module}') + ModuleBayTemplate.objects.create(module_type=self.line_card_type, name='Bay E{module}/1') + ModuleBay.objects.create(device=self.device, module=self.line_card, name='Bay E2/1') + ModuleBay.objects.create(device=self.device, module=self.line_card, name='Bay E1/1') + self.line_card.module_bay = self.slot_2 + with self.assertRaises(ValidationError) as cm: + self.line_card.full_clean() + self.assertIn('current name of another moved module bay', str(cm.exception)) + + def test_rename_exceeding_name_max_length_is_rejected(self): + name_limit = Interface._meta.get_field('name').max_length + template_name_limit = InterfaceTemplate._meta.get_field('name').max_length + position_limit = ModuleBay._meta.get_field('position').max_length + prefix = 'A' * (template_name_limit - len('{module}')) + long_type = ModuleType.objects.create( + manufacturer=self.line_card_type.manufacturer, model='Oversized Name Card' + ) + InterfaceTemplate.objects.create( + module_type=long_type, name=f'{prefix}{{module}}', type=InterfaceTypeChoices.TYPE_1GE_FIXED + ) + long_bay = ModuleBay.objects.create(device=self.device, name='Long Bay', position='X' * position_limit) + module = Module.objects.create(device=self.device, module_bay=self.slot_2, module_type=long_type) + interface = module.interfaces.get() + self.assertLessEqual(len(interface.name), name_limit) + + module.module_bay = long_bay + with self.assertRaises(ValidationError) as cm: + module.full_clean() + self.assertIn(str(interface), str(cm.exception)) + + def test_rename_exceeding_position_max_length_is_rejected(self): + position_limit = ModuleBay._meta.get_field('position').max_length + template_position_limit = ModuleBayTemplate._meta.get_field('position').max_length + prefix = 'B' * (template_position_limit - len('{module}')) + long_type = ModuleType.objects.create( + manufacturer=self.line_card_type.manufacturer, model='Oversized Position Card' + ) + ModuleBayTemplate.objects.create( + module_type=long_type, name='Nested Bay {module}', position=f'{prefix}{{module}}' + ) + long_bay = ModuleBay.objects.create(device=self.device, name='Long Bay', position='Y' * position_limit) + module = Module.objects.create(device=self.device, module_bay=self.slot_2, module_type=long_type) + bay = module.modulebays.get() + + module.module_bay = long_bay + with self.assertRaises(ValidationError) as cm: + module.full_clean() + self.assertIn(str(bay), str(cm.exception)) + + def test_leaf_token_left_raw_matches_fresh_walker_resolution(self): + # A bay whose stored position literally contains an unresolved {module} token + # (bypassing normal template-driven creation) must resolve its own children's + # names identically to a fresh get_module_bay_positions() call, both before and + # after a move, so a later move can still recognize the template match. + odd_bay = ModuleBay.objects.create( + device=self.device, module=self.line_card, name='Odd Bay', position='{module}A' + ) + child_module = Module.objects.create(device=self.device, module_bay=odd_bay, module_type=self.sfp_type) + child_interface = child_module.interfaces.get() + + self._move_line_card_to_slot_2() + child_interface.refresh_from_db() + odd_bay.refresh_from_db() + expected_name = resolve_module_placeholder('SFP {module}', get_module_bay_positions(odd_bay)) + self.assertEqual(child_interface.name, expected_name) + + def test_second_move_still_renames_child_of_raw_token_bay(self): + # Regression pin: a second move of the same module must still template-match and + # rename a child nested under a raw-token bay, not silently stop renaming. + two_token_type = ModuleType.objects.create( + manufacturer=self.line_card_type.manufacturer, model='Two Token SFP' + ) + InterfaceTemplate.objects.create( + module_type=two_token_type, name='SFP {module}/{module}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS + ) + odd_bay = ModuleBay.objects.create( + device=self.device, module=self.line_card, name='Odd Bay', position='{module}A' + ) + child_module = Module.objects.create(device=self.device, module_bay=odd_bay, module_type=two_token_type) + child_interface = child_module.interfaces.get() + self.assertEqual(child_interface.name, 'SFP 1/{module}A') + + self._move_line_card_to_slot_2() + child_interface.refresh_from_db() + self.assertEqual(child_interface.name, 'SFP 2/{module}A') + + self.line_card.module_bay = self.slot_1 + self.line_card.full_clean() + self.line_card.save() + child_interface.refresh_from_db() + self.assertEqual(child_interface.name, 'SFP 1/{module}A') + + def test_move_into_raw_token_bay_resolves_ancestor_from_child(self): + """ + A raw {module} token on the DESTINATION bay resolves from the planned child + position below it, matching a fresh post-move walker resolution exactly. + """ + raw_slot = ModuleBay.objects.create(device=self.device, name='Raw Slot', position='{module}R') + b_bay = ModuleBay.objects.create(device=self.device, module=self.line_card, name='B Bay', position='B') + two_token_type = ModuleType.objects.create( + manufacturer=self.line_card_type.manufacturer, model='Two Token' + ) + InterfaceTemplate.objects.create( + module_type=two_token_type, name='SFP {module}/{module}', + type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS, + ) + child_module = Module.objects.create( + device=self.device, module_bay=b_bay, module_type=two_token_type + ) + child_interface = child_module.interfaces.get(name='SFP 1/B') + + self.line_card.module_bay = raw_slot + self.line_card.full_clean() + self.line_card.save() + + child_interface.refresh_from_db() + self.assertEqual(child_interface.name, 'SFP BR/B') + self.assertNotIn('{module}', child_interface.name) + fresh_chain = get_module_bay_positions(ModuleBay.objects.get(pk=b_bay.pk)) + self.assertEqual(child_interface.name, resolve_module_placeholder('SFP {module}/{module}', fresh_chain)) + # Leaf-raw parity: the moved module's own component sees the destination + # bay's token unresolved, exactly as an install into that bay would + root_interface = self.line_card.interfaces.get(name__startswith='Ethernet') + self.assertEqual(root_interface.name, 'Ethernet{module}R/1') + + def test_move_under_multiple_raw_token_ancestors_resolves_full_chain(self): + """ + Every ancestor level carrying a raw {module} token inherits from the resolved + position below it, across more than one level. + """ + bare_type = ModuleType.objects.create( + manufacturer=self.line_card_type.manufacturer, model='Bare Carrier' + ) + three_token_type = ModuleType.objects.create( + manufacturer=self.line_card_type.manufacturer, model='Three Token' + ) + InterfaceTemplate.objects.create( + module_type=three_token_type, name='T{module}.{module}.{module}', + type=InterfaceTypeChoices.TYPE_1GE_FIXED, + ) + # Source: depth-3 chain ['7', '8', 'B'] so the three-token template resolves + s1 = ModuleBay.objects.create(device=self.device, name='S1', position='7') + mid_src = Module.objects.create(device=self.device, module_bay=s1, module_type=bare_type) + s2 = ModuleBay.objects.create(device=self.device, module=mid_src, name='S2', position='8') + carrier = Module.objects.create(device=self.device, module_bay=s2, module_type=bare_type) + c_bay = ModuleBay.objects.create(device=self.device, module=carrier, name='C Bay', position='B') + child_module = Module.objects.create( + device=self.device, module_bay=c_bay, module_type=three_token_type + ) + child_interface = child_module.interfaces.get(name='T7.8.B') + # Destination: two stacked raw-token ancestors + g_bay = ModuleBay.objects.create(device=self.device, name='G Bay', position='{module}X') + mid_dst = Module.objects.create(device=self.device, module_bay=g_bay, module_type=bare_type) + r_bay = ModuleBay.objects.create(device=self.device, module=mid_dst, name='R Bay', position='{module}R') + + carrier.module_bay = r_bay + carrier.full_clean() + carrier.save() + + child_interface.refresh_from_db() + self.assertEqual(child_interface.name, 'TBRX.BR.B') + self.assertNotIn('{module}', child_interface.name) + fresh_chain = get_module_bay_positions(ModuleBay.objects.get(pk=c_bay.pk)) + self.assertEqual(fresh_chain, ['BRX', 'BR', 'B']) + self.assertEqual( + child_interface.name, resolve_module_placeholder('T{module}.{module}.{module}', fresh_chain) + ) + + def test_move_to_shallower_bay_with_unresolvable_template_is_rejected(self): + """ + A component whose name matched a source template that cannot resolve at the + destination depth rejects the move instead of silently keeping the stale name. + """ + two_token_type = ModuleType.objects.create( + manufacturer=self.line_card_type.manufacturer, model='Two Token' + ) + InterfaceTemplate.objects.create( + module_type=two_token_type, name='SFP {module}/{module}', + type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS, + ) + b_bay = ModuleBay.objects.create(device=self.device, module=self.line_card, name='B Bay', position='B') + child_module = Module.objects.create( + device=self.device, module_bay=b_bay, module_type=two_token_type + ) + shallow = ModuleBay.objects.create(device=self.device, name='Shallow', position='X') + + child_module.module_bay = shallow + with self.assertRaises(ValidationError) as cm: + child_module.full_clean() + self.assertIn('cannot be resolved', str(cm.exception)) + with self.assertRaises(AbortRequest): + child_module.save() + child_module.refresh_from_db() + self.assertEqual(child_module.module_bay_id, b_bay.pk) + self.assertTrue(child_module.interfaces.filter(name='SFP 1/B').exists()) + + def test_matched_label_unresolvable_at_destination_rejects_move(self): + """ + A label matching its source template resolution rejects the move when the + label template cannot resolve at the destination depth. + """ + label_type = ModuleType.objects.create( + manufacturer=self.line_card_type.manufacturer, model='Label Type' + ) + InterfaceTemplate.objects.create( + module_type=label_type, name='N{module}', label='L{module}/{module}', + type=InterfaceTypeChoices.TYPE_1GE_FIXED, + ) + b_bay = ModuleBay.objects.create(device=self.device, module=self.line_card, name='B Bay', position='B') + child_module = Module.objects.create(device=self.device, module_bay=b_bay, module_type=label_type) + interface = child_module.interfaces.get(name='NB') + self.assertEqual(interface.label, 'L1/B') + shallow = ModuleBay.objects.create(device=self.device, name='Shallow', position='X') + + child_module.module_bay = shallow + with self.assertRaises(ValidationError): + child_module.full_clean() + + def test_unmatched_label_with_unresolvable_template_still_moves(self): + """ + A manually customized label never source-matches, so an unresolvable label + template does not block the move and the label is preserved. + """ + label_type = ModuleType.objects.create( + manufacturer=self.line_card_type.manufacturer, model='Label Type' + ) + InterfaceTemplate.objects.create( + module_type=label_type, name='N{module}', label='L{module}/{module}', + type=InterfaceTypeChoices.TYPE_1GE_FIXED, + ) + b_bay = ModuleBay.objects.create(device=self.device, module=self.line_card, name='B Bay', position='B') + child_module = Module.objects.create(device=self.device, module_bay=b_bay, module_type=label_type) + interface = child_module.interfaces.get(name='NB') + interface.label = 'Custom' + interface.save() + shallow = ModuleBay.objects.create(device=self.device, name='Shallow', position='X') + + child_module.module_bay = shallow + child_module.full_clean() + child_module.save() + interface.refresh_from_db() + self.assertEqual(interface.name, 'NX') + self.assertEqual(interface.label, 'Custom') + + def test_matched_bay_position_unresolvable_at_destination_rejects_move(self): + """ + A module bay position matching its source template resolution rejects the move + when the position template cannot resolve at the destination depth. + """ + pos_type = ModuleType.objects.create( + manufacturer=self.line_card_type.manufacturer, model='Pos Type' + ) + ModuleBayTemplate.objects.create(module_type=pos_type, name='PB', position='{module}/{module}') + b_bay = ModuleBay.objects.create(device=self.device, module=self.line_card, name='B Bay', position='B') + pos_module = Module.objects.create(device=self.device, module_bay=b_bay, module_type=pos_type) + self.assertEqual(pos_module.modulebays.get(name='PB').position, '1/B') + shallow = ModuleBay.objects.create(device=self.device, name='Shallow', position='X') + + pos_module.module_bay = shallow + with self.assertRaises(ValidationError): + pos_module.full_clean() + + +class ModuleCrossDeviceBlockerTestCase(TestCase): + + @classmethod + def setUpTestData(cls): + cls.device_a = create_test_device('Device A') + cls.device_b = create_test_device('Device B') + cls.bay_a = ModuleBay.objects.create(device=cls.device_a, name='Bay A') + cls.bay_b = ModuleBay.objects.create(device=cls.device_b, name='Bay B') + manufacturer = Manufacturer.objects.create(name='Manufacturer M', slug='manufacturer-m') + cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type M') + cls.module = Module.objects.create( + device=cls.device_a, module_bay=cls.bay_a, module_type=cls.module_type + ) + cls.interface = Interface.objects.create( + device=cls.device_a, module=cls.module, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED + ) + + def _assert_move_blocked(self, token): + self.module.device = self.device_b + self.module.module_bay = self.bay_b + with self.assertRaises(ValidationError) as cm: + self.module.full_clean() + self.assertIn('cannot be moved to a different device', str(cm.exception)) + self.assertIn(token, str(cm.exception)) + + def _assert_move_allowed(self): + self.module.device = self.device_b + self.module.module_bay = self.bay_b + self.module.full_clean() + + def test_cable_blocks(self): + peer = Interface.objects.create( + device=self.device_a, name='peer0', type=InterfaceTypeChoices.TYPE_1GE_FIXED + ) + Cable(a_terminations=[self.interface], b_terminations=[peer]).save() + self._assert_move_blocked('cabled') + + def test_mark_connected_blocks(self): + self.interface.mark_connected = True + self.interface.save() + self._assert_move_blocked('cabled or connection-marked') + + def test_ip_address_blocks(self): + IPAddress.objects.create(address='192.0.2.1/32', assigned_object=self.interface) + self._assert_move_blocked('IP addresses') + + def test_fhrp_group_assignment_blocks(self): + group = FHRPGroup.objects.create(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=1) + FHRPGroupAssignment.objects.create(group=group, interface=self.interface, priority=100) + self._assert_move_blocked('FHRP') + + def test_tunnel_termination_blocks(self): + tunnel = Tunnel.objects.create(name='Tunnel 1', encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP) + TunnelTermination.objects.create(tunnel=tunnel, termination=self.interface) + self._assert_move_blocked('tunnel') + + def test_l2vpn_termination_blocks(self): + l2vpn = L2VPN.objects.create(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN) + L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=self.interface) + self._assert_move_blocked('L2VPN') + + def test_virtual_circuit_termination_blocks(self): + provider = Provider.objects.create(name='Provider 1', slug='provider-1') + provider_network = ProviderNetwork.objects.create(provider=provider, name='Provider Network 1') + vc_type = VirtualCircuitType.objects.create(name='VC Type 1', slug='vc-type-1') + vc = VirtualCircuit.objects.create(provider_network=provider_network, cid='VC 1', type=vc_type) + virtual_interface = Interface.objects.create( + device=self.device_a, module=self.module, name='vc0', type=InterfaceTypeChoices.TYPE_VIRTUAL + ) + VirtualCircuitTermination.objects.create(virtual_circuit=vc, interface=virtual_interface) + self._assert_move_blocked('virtual circuit') + + def test_wireless_link_blocks(self): + radio_a = Interface.objects.create( + device=self.device_a, module=self.module, name='radio0', type=InterfaceTypeChoices.TYPE_80211AC + ) + radio_b = Interface.objects.create( + device=self.device_a, name='radio1', type=InterfaceTypeChoices.TYPE_80211AC + ) + WirelessLink(interface_a=radio_a, interface_b=radio_b, ssid='LINK1').save() + self._assert_move_blocked('wireless links') + + def test_wireless_lan_blocks(self): + wlan = WirelessLAN.objects.create(ssid='SSID1') + self.interface.wireless_lans.add(wlan) + self._assert_move_blocked('wireless LAN') + + def test_untagged_vlan_blocks(self): + vlan = VLAN.objects.create(vid=100, name='VLAN 100') + self.interface.mode = InterfaceModeChoices.MODE_ACCESS + self.interface.untagged_vlan = vlan + self.interface.save() + self._assert_move_blocked('untagged VLAN') + + def test_tagged_vlan_blocks(self): + vlan = VLAN.objects.create(vid=200, name='VLAN 200') + self.interface.mode = InterfaceModeChoices.MODE_TAGGED + self.interface.save() + self.interface.tagged_vlans.add(vlan) + self._assert_move_blocked('tagged VLANs') + + def test_qinq_svlan_blocks(self): + svlan = VLAN.objects.create(vid=999, name='SVLAN 999') + self.interface.mode = InterfaceModeChoices.MODE_Q_IN_Q + self.interface.qinq_svlan = svlan + self.interface.save() + self._assert_move_blocked('Q-in-Q') + + def test_vlan_translation_policy_blocks(self): + policy = VLANTranslationPolicy.objects.create(name='Policy 1') + self.interface.vlan_translation_policy = policy + self.interface.save() + self._assert_move_blocked('VLAN translation') + + def test_vdc_assignment_blocks(self): + vdc = VirtualDeviceContext.objects.create(device=self.device_a, name='VDC 1', status='active') + self.interface.vdcs.add(vdc) + self._assert_move_blocked('VDC') + + def test_vrf_blocks(self): + vrf = VRF.objects.create(name='VRF 1') + self.interface.vrf = vrf + self.interface.save() + self._assert_move_blocked('VRF') + + def test_parent_outside_moved_set_blocks(self): + parent = Interface.objects.create( + device=self.device_a, name='parent0', type=InterfaceTypeChoices.TYPE_VIRTUAL + ) + Interface.objects.create( + device=self.device_a, module=self.module, name='child0', + type=InterfaceTypeChoices.TYPE_VIRTUAL, parent=parent, + ) + self._assert_move_blocked('boundary') + + def test_bridge_outside_moved_set_blocks(self): + bridge = Interface.objects.create( + device=self.device_a, name='bridge0', type=InterfaceTypeChoices.TYPE_BRIDGE + ) + self.interface.bridge = bridge + self.interface.save() + self._assert_move_blocked('boundary') + + def test_lag_outside_moved_set_blocks(self): + lag = Interface.objects.create( + device=self.device_a, name='lag0', type=InterfaceTypeChoices.TYPE_LAG + ) + self.interface.lag = lag + self.interface.save() + self._assert_move_blocked('boundary') + + def test_nonmoved_member_of_moved_lag_blocks(self): + lag = Interface.objects.create( + device=self.device_a, module=self.module, name='lag0', type=InterfaceTypeChoices.TYPE_LAG + ) + Interface.objects.create( + device=self.device_a, name='member0', type=InterfaceTypeChoices.TYPE_1GE_FIXED, lag=lag + ) + self._assert_move_blocked('boundary') + + def test_split_port_mapping_blocks(self): + front_port = FrontPort.objects.create( + device=self.device_a, module=self.module, name='Front 1', type=PortTypeChoices.TYPE_LC + ) + rear_port = RearPort.objects.create( + device=self.device_a, name='Rear 1', type=PortTypeChoices.TYPE_LC, positions=1 + ) + PortMapping.objects.create( + front_port=front_port, front_port_position=1, rear_port=rear_port, rear_port_position=1 + ) + self._assert_move_blocked('port mappings') + + def test_split_power_outlet_blocks(self): + power_port = PowerPort.objects.create(device=self.device_a, name='PP 1') + PowerOutlet.objects.create( + device=self.device_a, module=self.module, name='Outlet 1', power_port=power_port + ) + self._assert_move_blocked('power outlet') + + def test_attached_inventory_item_blocks(self): + InventoryItem.objects.create(device=self.device_a, name='Item 1', component=self.interface) + self._assert_move_blocked('inventory items') + + def test_intra_module_bridge_pair_is_allowed(self): + other = Interface.objects.create( + device=self.device_a, module=self.module, name='eth1', type=InterfaceTypeChoices.TYPE_1GE_FIXED + ) + self.interface.bridge = other + self.interface.save() + self._assert_move_allowed() + + def test_intra_module_power_pair_is_allowed(self): + power_port = PowerPort.objects.create(device=self.device_a, module=self.module, name='PP 1') + PowerOutlet.objects.create( + device=self.device_a, module=self.module, name='Outlet 1', power_port=power_port + ) + self._assert_move_allowed() + + # + # Blockers name the offending components, not just how many there are + # + + def _blocked_message(self): + self.module.device = self.device_b + self.module.module_bay = self.bay_b + with self.assertRaises(ValidationError) as cm: + self.module.full_clean() + return str(cm.exception) + + @staticmethod + def _samples(message): + """Every parenthesized "e.g." list in a blocker message, as a list of name lists.""" + return [ + [name.strip() for name in group.split(',')] + for group in re.findall(r'\(e\.g\. ([^)]*)\)', message) + ] + + def test_cable_blocker_names_offending_component(self): + peer = Interface.objects.create( + device=self.device_a, name='peer0', type=InterfaceTypeChoices.TYPE_1GE_FIXED + ) + Cable(a_terminations=[self.interface], b_terminations=[peer]).save() + # A second moved interface with no cable must not be named + Interface.objects.create( + device=self.device_a, module=self.module, name='quiet0', + type=InterfaceTypeChoices.TYPE_1GE_FIXED, + ) + message = self._blocked_message() + self.assertIn('eth0', message) + self.assertNotIn('quiet0', message) + + def test_interface_state_blocker_names_offending_interface(self): + IPAddress.objects.create(address='192.0.2.1/24', assigned_object=self.interface) + self.assertEqual(self._samples(self._blocked_message()), [['eth0']]) + + def test_boundary_blocker_names_both_directions(self): + outsider = Interface.objects.create( + device=self.device_a, name='outsider0', type=InterfaceTypeChoices.TYPE_1GE_FIXED + ) + self.interface.bridge = outsider + self.interface.save() + lag = Interface.objects.create( + device=self.device_a, module=self.module, name='lag0', type=InterfaceTypeChoices.TYPE_LAG + ) + Interface.objects.create( + device=self.device_a, name='member0', type=InterfaceTypeChoices.TYPE_1GE_FIXED, lag=lag + ) + message = self._blocked_message() + self.assertIn('2 parent, bridge, or LAG interface relations', message) + self.assertEqual(self._samples(message), [['eth0', 'member0']]) + + def test_split_power_outlet_blocker_names_outlet(self): + power_port = PowerPort.objects.create(device=self.device_a, name='PP 1') + PowerOutlet.objects.create( + device=self.device_a, module=self.module, name='Outlet 1', power_port=power_port + ) + self.assertEqual(self._samples(self._blocked_message()), [['Outlet 1']]) + + def test_split_port_mapping_blocker_names_front_port(self): + front_port = FrontPort.objects.create( + device=self.device_a, module=self.module, name='Front 1', type=PortTypeChoices.TYPE_LC + ) + rear_port = RearPort.objects.create( + device=self.device_a, name='Rear 1', type=PortTypeChoices.TYPE_LC, positions=1 + ) + PortMapping.objects.create( + front_port=front_port, front_port_position=1, rear_port=rear_port, rear_port_position=1 + ) + self.assertEqual(self._samples(self._blocked_message()), [['Front 1']]) + + def test_split_port_mapping_blocker_names_moved_rear_port(self): + """ + With the rear port moving and the front port staying behind, the sample must name the + rear port: a user told to look for the front port would not find it on this module. + """ + rear_port = RearPort.objects.create( + device=self.device_a, module=self.module, name='Moved Rear 1', + type=PortTypeChoices.TYPE_LC, positions=1, + ) + front_port = FrontPort.objects.create( + device=self.device_a, name='Chassis Front 1', type=PortTypeChoices.TYPE_LC + ) + PortMapping.objects.create( + front_port=front_port, front_port_position=1, rear_port=rear_port, rear_port_position=1 + ) + message = self._blocked_message() + self.assertEqual(self._samples(message), [['Moved Rear 1']]) + self.assertNotIn('Chassis Front 1', message) + + def test_inventory_item_blocker_names_component(self): + InventoryItem.objects.create(device=self.device_a, name='Item 1', component=self.interface) + self.assertEqual(self._samples(self._blocked_message()), [['eth0']]) + + def test_blocker_sample_is_capped_but_count_is_complete(self): + peer = Interface.objects.create( + device=self.device_a, name='peer0', type=InterfaceTypeChoices.TYPE_1GE_FIXED + ) + Cable(a_terminations=[self.interface], b_terminations=[peer]).save() + for i in range(1, 8): + marked = Interface.objects.create( + device=self.device_a, module=self.module, name=f'eth{i}', + type=InterfaceTypeChoices.TYPE_1GE_FIXED, + ) + marked.mark_connected = True + marked.save() + message = self._blocked_message() + self.assertIn('8 cabled or connection-marked interfaces', message) + sample, = self._samples(message) + self.assertEqual(len(sample), ModuleMovePlan.SAMPLE_LIMIT) + self.assertEqual(sample, ['eth0', 'eth1', 'eth2', 'eth3', 'eth4']) + + def test_mac_address_is_allowed(self): + mac = MACAddress.objects.create(mac_address='00:11:22:33:44:55', assigned_object=self.interface) + self.interface.primary_mac_address = mac + self.interface.save() + self._assert_move_allowed() + + +class ModuleCrossDeviceMoveTestCase(TestCase): + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Manufacturer M', slug='manufacturer-m') + role = DeviceRole.objects.create(name='Role 1', slug='role-1') + cls.site_a = Site.objects.create(name='Site A', slug='site-a') + cls.site_b = Site.objects.create(name='Site B', slug='site-b') + device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Chassis', slug='chassis') + ModuleBayTemplate.objects.create(device_type=device_type, name='Slot 1', position='1') + ModuleBayTemplate.objects.create(device_type=device_type, name='Slot 2', position='2') + + cls.line_card_type = ModuleType.objects.create(manufacturer=manufacturer, model='Line Card') + InterfaceTemplate.objects.create( + module_type=cls.line_card_type, name='Ethernet{module}/1', type=InterfaceTypeChoices.TYPE_1GE_FIXED + ) + power_port_template = PowerPortTemplate.objects.create(module_type=cls.line_card_type, name='PP 1') + PowerOutletTemplate.objects.create( + module_type=cls.line_card_type, name='Outlet 1', power_port=power_port_template + ) + front_port_template = FrontPortTemplate.objects.create( + module_type=cls.line_card_type, name='Front 1', type=PortTypeChoices.TYPE_LC + ) + rear_port_template = RearPortTemplate.objects.create( + module_type=cls.line_card_type, name='Rear 1', type=PortTypeChoices.TYPE_LC, positions=1 + ) + PortTemplateMapping.objects.create( + module_type=cls.line_card_type, + front_port=front_port_template, front_port_position=1, + rear_port=rear_port_template, rear_port_position=1, + ) + ModuleBayTemplate.objects.create( + module_type=cls.line_card_type, name='SFP bay {module}/1', position='{module}/1' + ) + cls.sfp_type = ModuleType.objects.create(manufacturer=manufacturer, model='SFP') + InterfaceTemplate.objects.create( + module_type=cls.sfp_type, name='SFP {module}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS + ) + + cls.device_a = Device.objects.create( + name='Chassis A', device_type=device_type, role=role, site=cls.site_a + ) + cls.device_b = Device.objects.create( + name='Chassis B', device_type=device_type, role=role, site=cls.site_b + ) + cls.slot_1_a = cls.device_a.modulebays.get(name='Slot 1') + cls.slot_2_b = cls.device_b.modulebays.get(name='Slot 2') + cls.line_card = Module.objects.create( + device=cls.device_a, module_bay=cls.slot_1_a, module_type=cls.line_card_type + ) + cls.sfp_bay = cls.line_card.modulebays.get(name='SFP bay 1/1') + cls.sfp_module = Module.objects.create( + device=cls.device_a, module_bay=cls.sfp_bay, module_type=cls.sfp_type + ) + + def _move_to_device_b(self): + self.line_card.device = self.device_b + self.line_card.module_bay = self.slot_2_b + self.line_card.full_clean() + self.line_card.save() + + def test_cross_device_move_updates_subtree(self): + sfp_interface = self.sfp_module.interfaces.get(name='SFP 1/1') + previous = sfp_interface.last_updated + self._move_to_device_b() + + self.line_card.refresh_from_db() + self.assertEqual(self.line_card.device, self.device_b) + self.assertEqual(self.line_card.module_bay, self.slot_2_b) + + self.sfp_module.refresh_from_db() + self.assertEqual(self.sfp_module.device, self.device_b) + + self.sfp_bay.refresh_from_db() + self.assertEqual(self.sfp_bay.device, self.device_b) + self.assertEqual(self.sfp_bay._site, self.site_b) + self.assertEqual(self.sfp_bay.parent_id, self.slot_2_b.pk) + self.assertTrue(str(self.sfp_bay.path).startswith(f'{self.slot_2_b.path}.')) + + sfp_interface.refresh_from_db() + self.assertEqual(sfp_interface.device, self.device_b) + self.assertEqual(sfp_interface._site, self.site_b) + self.assertGreater(sfp_interface.last_updated, previous) + + def test_cross_device_move_renames_templated_components(self): + interface = self.line_card.interfaces.get(name='Ethernet1/1') + sfp_interface = self.sfp_module.interfaces.get(name='SFP 1/1') + self._move_to_device_b() + interface.refresh_from_db() + self.assertEqual(interface.name, 'Ethernet2/1') + sfp_interface.refresh_from_db() + self.assertEqual(sfp_interface.name, 'SFP 2/1') + + def test_cross_device_move_applies_swap_chain_bay_renames(self): + """ + A rename chain where one bay's new name equals another moved bay's current name + applies cleanly cross-device, with both bays renamed on the destination device. + """ + # Templates resolve Bay E1/1 -> Bay E2/1 and Bay E2/1 -> Bay E2/2 across slots + ModuleBayTemplate.objects.create(module_type=self.line_card_type, name='Bay E2/{module}') + ModuleBayTemplate.objects.create(module_type=self.line_card_type, name='Bay E{module}/1') + bay_1 = ModuleBay.objects.create(device=self.device_a, module=self.line_card, name='Bay E1/1') + bay_2 = ModuleBay.objects.create(device=self.device_a, module=self.line_card, name='Bay E2/1') + self._move_to_device_b() + bay_1.refresh_from_db() + bay_2.refresh_from_db() + self.assertEqual(bay_1.name, 'Bay E2/1') + self.assertEqual(bay_2.name, 'Bay E2/2') + self.assertEqual(bay_1.device, self.device_b) + self.assertEqual(bay_2.device, self.device_b) + + def test_cross_device_move_updates_port_mappings(self): + mapping = PortMapping.objects.get(front_port__module=self.line_card) + self._move_to_device_b() + mapping.refresh_from_db() + self.assertEqual(mapping.device, self.device_b) + + def test_cross_device_move_keeps_intra_module_power_link(self): + outlet = self.line_card.poweroutlets.get(name='Outlet 1') + self._move_to_device_b() + outlet.refresh_from_db() + self.assertEqual(outlet.device, self.device_b) + self.assertEqual(outlet.power_port.device_id, self.device_b.pk) + + def test_cross_device_move_moves_mac_address(self): + interface = self.line_card.interfaces.get(name='Ethernet1/1') + mac = MACAddress.objects.create(mac_address='00:11:22:33:44:55', assigned_object=interface) + self._move_to_device_b() + mac.refresh_from_db() + interface.refresh_from_db() + self.assertEqual(mac.assigned_object, interface) + self.assertEqual(interface.device, self.device_b) + + def test_cross_device_move_recomputes_counters(self): + self._move_to_device_b() + self.device_a.refresh_from_db() + self.device_b.refresh_from_db() + self.assertEqual(self.device_a.interface_count, 0) + self.assertEqual(self.device_b.interface_count, 2) + self.assertEqual(self.device_a.module_bay_count, 2) + self.assertEqual(self.device_b.module_bay_count, 3) + + def test_move_query_count_independent_of_destination_size(self): + def move_and_count(destination_device, destination_bay): + module = Module.objects.create( + device=self.device_a, module_bay=self.device_a.modulebays.get(name='Slot 2'), + module_type=self.sfp_type, + ) + module.device = destination_device + module.module_bay = destination_bay + module.full_clean() + with CaptureQueriesContext(connection) as ctx: + module.save() + return len(ctx.captured_queries) + + small_count = move_and_count(self.device_b, self.slot_2_b) + big_device = Device.objects.create( + name='Warehouse', device_type=self.device_b.device_type, role=self.device_b.role, + site=self.site_b, + ) + ModuleBay.objects.bulk_create([ + ModuleBay(device=big_device, name=f'Storage Bay {i}') for i in range(1, 201) + ]) + target_bay = ModuleBay.objects.create(device=big_device, name='Target Bay') + big_count = move_and_count(big_device, target_bay) + self.assertEqual(small_count, big_count) + + def test_move_query_count_independent_of_same_type_child_count(self): + """ + Moving a module with several installed children of the same module_type issues + the same number of template-table queries as moving one with a single child: the + per-pass template lookup is cached per module_type, not repeated per module. + """ + multi_type = ModuleType.objects.create( + manufacturer=self.line_card_type.manufacturer, model='Multi-Slot Card' + ) + for i in range(1, 4): + ModuleBayTemplate.objects.create(module_type=multi_type, name=f'Child Bay {i}', position=str(i)) + + def build_and_move(child_count): + device = create_test_device(f'Card Device {child_count}') + card_bay = ModuleBay.objects.create(device=device, name='Card Bay') + target_bay = ModuleBay.objects.create(device=device, name='Target Bay') + card = Module.objects.create(device=device, module_bay=card_bay, module_type=multi_type) + for child_bay in card.modulebays.order_by('name')[:child_count]: + Module.objects.create(device=device, module_bay=child_bay, module_type=self.sfp_type) + card.module_bay = target_bay + card.full_clean() + with CaptureQueriesContext(connection) as ctx: + card.save() + return sum(1 for query in ctx.captured_queries if 'template' in query['sql'].lower()) + + one_child_queries = build_and_move(1) + three_children_queries = build_and_move(3) + self.assertEqual(one_child_queries, three_children_queries) + + def test_bulk_updates_use_configured_chunk_size(self): + """ + The move path must honour BULK_UPDATE_CHUNK_SIZE rather than a private constant, so + that an operator bounding rows-per-statement bounds this operation too. + """ + original_bulk_update = QuerySet.bulk_update + batch_sizes = [] + + def recording_bulk_update(self, objs, fields, batch_size=None, **kwargs): + batch_sizes.append(batch_size) + return original_bulk_update(self, objs, fields, batch_size=batch_size, **kwargs) + + with override_settings(BULK_UPDATE_CHUNK_SIZE=7): + with patch.object(QuerySet, 'bulk_update', recording_bulk_update): + self._move_to_device_b() + + self.assertTrue(batch_sizes, 'the move issued no bulk_update calls') + self.assertEqual(set(batch_sizes), {7}) + + @override_settings(BULK_UPDATE_CHUNK_SIZE=1) + def test_move_is_correct_when_updates_are_chunked(self): + """ + A chunk size small enough to split every statement must not disturb the staged bay + writes, whose correctness depends on the ltree triggers settling per level. + """ + sfp_interface = self.sfp_module.interfaces.get(name='SFP 1/1') + self._move_to_device_b() + + self.line_card.refresh_from_db() + self.sfp_module.refresh_from_db() + self.assertEqual(self.line_card.device, self.device_b) + self.assertEqual(self.sfp_module.device, self.device_b) + + moved_bay = ModuleBay.objects.get(pk=self.sfp_bay.pk) + dest_bay = ModuleBay.objects.get(pk=self.slot_2_b.pk) + self.assertEqual(moved_bay.name, 'SFP bay 2/1') + self.assertEqual(moved_bay.device, self.device_b) + self.assertTrue(str(moved_bay.path).startswith(f'{dest_bay.path}.')) + + sfp_interface.refresh_from_db() + self.assertEqual(sfp_interface.name, 'SFP 2/1') + self.assertEqual(sfp_interface.device, self.device_b) + self.assertEqual(sfp_interface._site, self.site_b) + self.assertEqual( + self.line_card.interfaces.get().name, 'Ethernet2/1' + ) + + def test_cross_device_move_refreshes_bay_sort_path(self): + """ + The trigger-maintained sort_path of a moved nested bay reflects the + destination hierarchy and the renamed chain after reparent plus rename. + """ + old_sort_path = self.sfp_bay.sort_path + self._move_to_device_b() + moved_bay = ModuleBay.objects.get(pk=self.sfp_bay.pk) + dest_bay = ModuleBay.objects.get(pk=self.slot_2_b.pk) + self.assertEqual(moved_bay.name, 'SFP bay 2/1') + self.assertTrue(str(moved_bay.path).startswith(f'{dest_bay.path}.')) + self.assertNotEqual(moved_bay.sort_path, old_sort_path) + self.assertTrue(str(moved_bay.sort_path).startswith(str(dest_bay.sort_path))) + self.assertIn('SFP bay 2/1', str(moved_bay.sort_path)) + + +class ModuleMoveComponentCoverageTestCase(TestCase): + """ + Guard against a newly introduced modular component model being left out of the move + planner, which is how cooling intakes and outflows were initially missed. + """ + + def test_every_modular_component_model_is_planned(self): + # Scoped to dcim: a plugin may define its own ModularComponentModel subclass, which core + # cannot add to COMPONENT_TEMPLATE_ATTRS, so it must not fail this assertion. + core_models = { + model for model in apps.get_models() + if issubclass(model, ModularComponentModel) and model._meta.app_label == 'dcim' + } + # ModuleBay is planned separately (nested hierarchy, distinct uniqueness constraint). + planned = set(COMPONENT_TEMPLATE_ATTRS) | {ModuleBay} + self.assertEqual( + core_models - planned, set(), + 'Modular component model(s) are not relocated by ModuleMovePlan. ' + 'Add them to COMPONENT_TEMPLATE_ATTRS.' + ) + + def test_planned_template_attrs_exist_on_module_type(self): + for model, template_attr in COMPONENT_TEMPLATE_ATTRS.items(): + with self.subTest(model=model._meta.label): + self.assertTrue( + hasattr(ModuleType, template_attr), + f'ModuleType has no relation {template_attr!r}' + ) + + def test_device_counters_are_derived_for_every_planned_model(self): + """ + Counter recomputation is derived from Device's own CounterCacheField declarations, so + adding a modular component model cannot silently skip it. Assert the derivation still + resolves a counter for each planned model, and does not reach beyond device-scoped ones. + """ + counters = ModuleMovePlan.device_counters_by_model(Device) + planned = set(COMPONENT_TEMPLATE_ATTRS) | {ModuleBay} + self.assertEqual( + planned - set(counters), set(), + 'A planned model has no device-scoped Device counter. If that is intended, this ' + 'assertion needs to record the exception explicitly.' + ) + self.assertEqual(counters[CoolingIntake], 'cooling_intake_count') + self.assertEqual(counters[ModuleBay], 'module_bay_count') + # Models counted by Device but never moved must not gain a delta + self.assertNotIn(DeviceBay, planned) + self.assertNotIn(InventoryItem, planned) + + +class ModuleMoveCounterTestCase(TestCase): + """ + A cross-device move must adjust the Device counter of every modular component model the + planner relocates. Exercises all of them at once, so a broken counter derivation cannot + pass by covering only the component types other tests happen to create. + """ + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Manufacturer M', slug='manufacturer-m') + role = DeviceRole.objects.create(name='Role 1', slug='role-1') + site = Site.objects.create(name='Site A', slug='site-a') + device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Chassis', slug='chassis') + ModuleBayTemplate.objects.create(device_type=device_type, name='Slot 1') + ModuleBayTemplate.objects.create(device_type=device_type, name='Slot 2') + + # One template of every modular component type, so each planned model contributes a row + cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Full Card') + ConsolePortTemplate.objects.create(module_type=cls.module_type, name='Console 1') + ConsoleServerPortTemplate.objects.create(module_type=cls.module_type, name='Console Server 1') + CoolingIntakeTemplate.objects.create(module_type=cls.module_type, name='Intake 1') + CoolingOutflowTemplate.objects.create(module_type=cls.module_type, name='Outflow 1') + InterfaceTemplate.objects.create( + module_type=cls.module_type, name='Ethernet 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED + ) + power_port = PowerPortTemplate.objects.create(module_type=cls.module_type, name='PP 1') + PowerOutletTemplate.objects.create( + module_type=cls.module_type, name='Outlet 1', power_port=power_port + ) + front_port = FrontPortTemplate.objects.create( + module_type=cls.module_type, name='Front 1', type=PortTypeChoices.TYPE_LC + ) + rear_port = RearPortTemplate.objects.create( + module_type=cls.module_type, name='Rear 1', type=PortTypeChoices.TYPE_LC, positions=1 + ) + PortTemplateMapping.objects.create( + module_type=cls.module_type, + front_port=front_port, front_port_position=1, + rear_port=rear_port, rear_port_position=1, + ) + ModuleBayTemplate.objects.create(module_type=cls.module_type, name='Sub bay 1') + + cls.device_a = Device.objects.create( + name='Chassis A', device_type=device_type, role=role, site=site + ) + cls.device_b = Device.objects.create( + name='Chassis B', device_type=device_type, role=role, site=site + ) + + def test_cross_device_move_adjusts_every_planned_counter(self): + module = Module.objects.create( + device=self.device_a, module_bay=self.device_a.modulebays.get(name='Slot 1'), + module_type=self.module_type, + ) + + # Fail loudly rather than vacuously if the fixture stops covering every planned model + rows = {model: model.objects.filter(module=module).count() for model in COMPONENT_TEMPLATE_ATTRS} + rows[ModuleBay] = ModuleBay.objects.filter(module=module).count() + self.assertEqual( + set(rows.values()), {1}, + f'the fixture must create exactly one row per planned model, got {rows}' + ) + + counters = ModuleMovePlan.device_counters_by_model(Device) + planned_counters = sorted(counters[model] for model in rows) + self.device_a.refresh_from_db() + self.device_b.refresh_from_db() + before_a = {counter: getattr(self.device_a, counter) for counter in planned_counters} + before_b = {counter: getattr(self.device_b, counter) for counter in planned_counters} + + module.device = self.device_b + module.module_bay = self.device_b.modulebays.get(name='Slot 2') + module.full_clean() + module.save() + + self.device_a.refresh_from_db() + self.device_b.refresh_from_db() + for counter in planned_counters: + with self.subTest(counter=counter): + self.assertEqual( + getattr(self.device_a, counter), before_a[counter] - 1, + f'{counter} was not decremented on the source device' + ) + self.assertEqual( + getattr(self.device_b, counter), before_b[counter] + 1, + f'{counter} was not incremented on the destination device' + ) + + +class ModuleMoveCoolingTestCase(TestCase): + """ + Cooling intakes and outflows are modular components and must be relocated, renamed, and + counted like any other. See netbox#15289. + """ + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Manufacturer M', slug='manufacturer-m') + role = DeviceRole.objects.create(name='Role 1', slug='role-1') + cls.site_a = Site.objects.create(name='Site A', slug='site-a') + cls.site_b = Site.objects.create(name='Site B', slug='site-b') + device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Chassis', slug='chassis') + ModuleBayTemplate.objects.create(device_type=device_type, name='Slot 1', position='1') + ModuleBayTemplate.objects.create(device_type=device_type, name='Slot 2', position='2') + + cls.card_type = ModuleType.objects.create(manufacturer=manufacturer, model='Cooled Card') + CoolingIntakeTemplate.objects.create(module_type=cls.card_type, name='Intake {module}/1') + CoolingOutflowTemplate.objects.create(module_type=cls.card_type, name='Outflow {module}/1') + ModuleBayTemplate.objects.create( + module_type=cls.card_type, name='Sub bay {module}/1', position='{module}/1' + ) + cls.sub_type = ModuleType.objects.create(manufacturer=manufacturer, model='Cooled Sub') + CoolingIntakeTemplate.objects.create(module_type=cls.sub_type, name='Sub intake {module}') + + cls.device_a = Device.objects.create( + name='Chassis A', device_type=device_type, role=role, site=cls.site_a + ) + cls.device_b = Device.objects.create( + name='Chassis B', device_type=device_type, role=role, site=cls.site_b + ) + cls.slot_1_a = cls.device_a.modulebays.get(name='Slot 1') + cls.slot_2_a = cls.device_a.modulebays.get(name='Slot 2') + cls.slot_2_b = cls.device_b.modulebays.get(name='Slot 2') + + def setUp(self): + super().setUp() + self.card = Module.objects.create( + device=self.device_a, module_bay=self.slot_1_a, module_type=self.card_type + ) + self.intake = self.card.coolingintakes.get() + self.outflow = self.card.coolingoutflows.get() + + def _move_to_device_b(self): + self.card.device = self.device_b + self.card.module_bay = self.slot_2_b + self.card.full_clean() + self.card.save() + + def test_same_device_move_renames_cooling_components(self): + self.card.module_bay = self.slot_2_a + self.card.full_clean() + self.card.save() + self.intake.refresh_from_db() + self.outflow.refresh_from_db() + self.assertEqual(self.intake.name, 'Intake 2/1') + self.assertEqual(self.outflow.name, 'Outflow 2/1') + self.assertEqual(self.intake.device, self.device_a) + + def test_cross_device_move_relocates_cooling_components(self): + self._move_to_device_b() + self.intake.refresh_from_db() + self.outflow.refresh_from_db() + for component in (self.intake, self.outflow): + self.assertEqual(component.device, self.device_b) + self.assertEqual(component._site, self.site_b) + self.assertEqual(component._location, self.device_b.location) + self.assertEqual(component._rack, self.device_b.rack) + self.assertEqual(self.intake.name, 'Intake 2/1') + self.assertEqual(self.outflow.name, 'Outflow 2/1') + + def test_cross_device_move_relocates_nested_cooling_components(self): + sub_bay = self.card.modulebays.get() + sub_module = Module.objects.create( + device=self.device_a, module_bay=sub_bay, module_type=self.sub_type + ) + sub_intake = sub_module.coolingintakes.get() + self.assertEqual(sub_intake.name, 'Sub intake 1/1') + self._move_to_device_b() + sub_intake.refresh_from_db() + self.assertEqual(sub_intake.device, self.device_b) + self.assertEqual(sub_intake.name, 'Sub intake 2/1') + + def test_cross_device_move_recomputes_cooling_counters(self): + self.device_a.refresh_from_db() + self.assertEqual(self.device_a.cooling_intake_count, 1) + self.assertEqual(self.device_a.cooling_outflow_count, 1) + self._move_to_device_b() + self.device_a.refresh_from_db() + self.device_b.refresh_from_db() + self.assertEqual(self.device_a.cooling_intake_count, 0) + self.assertEqual(self.device_a.cooling_outflow_count, 0) + self.assertEqual(self.device_b.cooling_intake_count, 1) + self.assertEqual(self.device_b.cooling_outflow_count, 1) + + def test_reinstall_into_vacated_bay_after_move(self): + """ + The vacated bay must be reusable: a stale cooling name left on the source device + would collide with the replacement module's replicated components. + """ + self._move_to_device_b() + replacement = Module( + device=self.device_a, module_bay=self.slot_1_a, module_type=self.card_type + ) + replacement.full_clean() + replacement.save() + self.assertEqual(replacement.coolingintakes.get().name, 'Intake 1/1') + + def test_cooling_name_conflict_at_destination_is_rejected(self): + CoolingIntake.objects.create(device=self.device_b, name='Intake 2/1') + self.card.device = self.device_b + self.card.module_bay = self.slot_2_b + with self.assertRaises(ValidationError) as cm: + self.card.full_clean() + self.assertIn('would conflict with', str(cm.exception)) + + def test_split_cooling_outflow_relation_blocks(self): + """An outflow's upstream intake must stay on the same device, so it cannot be split.""" + device_intake = CoolingIntake.objects.create(device=self.device_a, name='Chassis Intake') + self.outflow.cooling_intake = device_intake + self.outflow.save() + self.card.device = self.device_b + self.card.module_bay = self.slot_2_b + with self.assertRaises(ValidationError) as cm: + self.card.full_clean() + self.assertIn('cooling outflow relations crossing', str(cm.exception)) + self.assertIn('Outflow 1/1', str(cm.exception)) + + def test_inward_cooling_outflow_relation_blocks(self): + device_outflow = CoolingOutflow.objects.create(device=self.device_a, name='Chassis Outflow') + device_outflow.cooling_intake = self.intake + device_outflow.save() + self.card.device = self.device_b + self.card.module_bay = self.slot_2_b + with self.assertRaises(ValidationError) as cm: + self.card.full_clean() + self.assertIn('cooling outflow relations crossing', str(cm.exception)) + self.assertIn('Chassis Outflow', str(cm.exception)) + + def test_intra_module_cooling_pair_is_allowed(self): + self.outflow.cooling_intake = self.intake + self.outflow.save() + self._move_to_device_b() + self.outflow.refresh_from_db() + self.assertEqual(self.outflow.device, self.device_b) + self.assertEqual(self.outflow.cooling_intake, self.intake) + + def test_upstream_outflow_on_another_device_is_allowed(self): + """ + CoolingIntake.cooling_outflow is not device-scoped: an intake is routinely supplied + by an outflow on another device, such as a CDU. It must not block a move. + """ + cdu_outflow = CoolingOutflow.objects.create(device=self.device_a, name='CDU Outflow') + self.intake.cooling_outflow = cdu_outflow + self.intake.save() + self._move_to_device_b() + self.intake.refresh_from_db() + self.assertEqual(self.intake.device, self.device_b) + self.assertEqual(self.intake.cooling_outflow, cdu_outflow) + + def test_cooling_components_are_changelogged(self): + with event_tracking(self._make_request()): + self.card.snapshot() + self._move_to_device_b() + intake_type = ContentType.objects.get_for_model(CoolingIntake) + change = ObjectChange.objects.get( + changed_object_type=intake_type, changed_object_id=self.intake.pk + ) + self.assertEqual(change.prechange_data['name'], 'Intake 1/1') + self.assertEqual(change.postchange_data['name'], 'Intake 2/1') + self.assertEqual(change.postchange_data['device'], self.device_b.pk) + + def _make_request(self): + request = RequestFactory().get('/') + request.id = uuid.uuid4() + request.user = User.objects.create_user(username='cooling-mover') + return request + + def test_attached_inventory_item_on_cooling_component_blocks(self): + InventoryItem.objects.create( + device=self.device_a, name='Coolant Sensor', component=self.intake + ) + self.card.device = self.device_b + self.card.module_bay = self.slot_2_b + with self.assertRaises(ValidationError) as cm: + self.card.full_clean() + self.assertIn('attached inventory items', str(cm.exception)) diff --git a/netbox/dcim/tests/test_search.py b/netbox/dcim/tests/test_search.py index 66a2ba95b..1df380a08 100644 --- a/netbox/dcim/tests/test_search.py +++ b/netbox/dcim/tests/test_search.py @@ -9,8 +9,12 @@ from utilities.testing import create_test_device class VirtualChassisSearchCacheTestCase(TestCase): def setUp(self): - self.vc = VirtualChassis.objects.create(name='VC1') - self.device = create_test_device('Switch-1', virtual_chassis=self.vc, vc_position=1) + # Object creation triggers deferred (post-commit) search caching. With no RQ worker + # running in tests the flush falls back to synchronous indexing; execute the on_commit + # callbacks so the member Device's cache is populated before each test runs. + with self.captureOnCommitCallbacks(execute=True): + self.vc = VirtualChassis.objects.create(name='VC1') + self.device = create_test_device('Switch-1', virtual_chassis=self.vc, vc_position=1) self.object_type = ObjectType.objects.get_for_model(Device) def test_renaming_virtual_chassis_refreshes_member_search_cache(self): diff --git a/netbox/dcim/tests/test_signals.py b/netbox/dcim/tests/test_signals.py index 348c6ec0b..b7a3a386a 100644 --- a/netbox/dcim/tests/test_signals.py +++ b/netbox/dcim/tests/test_signals.py @@ -1,6 +1,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch +from django.apps import apps from django.contrib.contenttypes.models import ContentType from django.db import connection, transaction from django.test import SimpleTestCase, TestCase, TransactionTestCase, override_settings @@ -30,23 +31,24 @@ from dcim.models import ( SiteGroup, VirtualChassis, ) +from dcim.models.device_components import ComponentModel +from dcim.models.mixins import CachedScopeMixin from ipam.models import Prefix +from netbox.plugins import PluginConfig from utilities.testing import PinnedConnectionRouter from virtualization.models import Cluster, ClusterType from wireless.models import WirelessLAN -COMPONENT_TABLES = frozenset(model._meta.db_table for model in signals.COMPONENT_MODELS) - class ScopePropagationCaptureMixin: """ Helper for asserting whether a save propagated to the tables its post_save handler rewrites. - dcim_cabletermination is never among them: the denormalized-field registry - (netbox.denormalized) rewrites it on Location, Rack, and Device saves alike, so it - cannot distinguish a propagation from a plain save. Neither is the saved object's own - table, which carries the save's own UPDATE. + Only the tables the handler itself rewrites are listed. The device components and + cable terminations are refreshed by database triggers, which issue their UPDATEs + inside the database where no query capture can see them. Neither is the saved + object's own table, which carries the save's own UPDATE. """ propagation_tables = frozenset() @@ -62,7 +64,10 @@ class ScopePropagationCaptureMixin: return { table for table in self.propagation_tables for q in ctx.captured_queries - if q['sql'].startswith(f'UPDATE "{table}"') + # The config-context cache invalidation in extras.signals writes to dcim_device on + # an upstream save too, so matching the table alone would report a propagation that + # never ran. It is identifiable by the column it nulls. + if q['sql'].startswith(f'UPDATE "{table}"') and '_config_context_data' not in q['sql'] } @@ -72,7 +77,7 @@ class LocationSiteChangeSignalTestCase(ScopePropagationCaptureMixin, TestCase): every descendant Location, Rack, Device, PowerPanel, and component when the parent Location's site assignment changes. """ - propagation_tables = COMPONENT_TABLES | {'dcim_rack', 'dcim_device', 'dcim_powerpanel'} + propagation_tables = frozenset({'dcim_rack', 'dcim_device', 'dcim_powerpanel'}) @classmethod def setUpTestData(cls): @@ -112,8 +117,8 @@ class LocationSiteChangeSignalTestCase(ScopePropagationCaptureMixin, TestCase): def test_changing_location_site_updates_circuittermination_caches(self): # CircuitTermination caches its scope ancestry under termination_type/termination_id - # rather than under CachedScopeMixin's scope field, so sync_cached_scope_fields does - # not cover it and the denormalized-field registry refreshes only _site. Both the + # rather than under CachedScopeMixin's scope field, and is kept current by the + # denormalization trigger sourced from dcim_location. Both the # moved Location's own terminations and those of its descendants must be repaired # here, region and site group included. Origin and destination Sites are given # distinct regions and groups so a value left stale is distinguishable from one that @@ -192,8 +197,7 @@ class LocationSiteChangeSignalTestCase(ScopePropagationCaptureMixin, TestCase): def test_raw_save_skips_propagation(self): # raw=True is set only by Django's loaddata pathway, whose fixture already carries the # denormalized values for every object it loads, so the propagation would rewrite each - # matched row with what it already holds. netbox.denormalized.update_denormalized_fields() - # returns early on raw for the same reason. + # matched row with what it already holds. location = self._seed_location_with_children() location.site = self.site_b @@ -291,13 +295,16 @@ class LocationSiteChangeAutocommitTestCase(TransactionTestCase): with transaction.atomic(): location.save() - # Poison a cached column via a signal-less update; an unconditional propagation - # repairs it. + # Poison a propagated column via a signal-less update; an unconditional propagation + # repairs it, and the components follow via the trigger on dcim_device. + Device.objects.filter(pk=device.pk).update(site=other_site) Interface.objects.filter(pk=interface.pk).update(_site=other_site) location.save() # Autocommit: no stash, unconditional propagation + device.refresh_from_db() interface.refresh_from_db() + self.assertEqual(device.site, site) self.assertEqual(interface._site, site) @@ -306,7 +313,7 @@ class RackSiteChangeSignalTestCase(ScopePropagationCaptureMixin, TestCase): Verify dcim.signals.handle_rack_site_change propagates a Rack's site/location to its Devices and their components when the Rack is moved, and only then. """ - propagation_tables = COMPONENT_TABLES | {'dcim_device'} + propagation_tables = frozenset({'dcim_device'}) @classmethod def setUpTestData(cls): @@ -420,23 +427,11 @@ class StashedScopeFieldsRegistrationTestCase(TestCase): @classmethod def setUpTestData(cls): - cls.instances = {} site = Site.objects.create(name='Site', slug='site') location = Location.objects.create(name='Location', slug='location', site=site) - rack = Rack.objects.create(name='Rack', site=site, location=location) - manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer') cls.instances = { - Site: site, Location: location, - Rack: rack, - Device: Device.objects.create( - name='Device', - site=site, - location=location, - rack=rack, - device_type=DeviceType.objects.create(manufacturer=manufacturer, model='Device Type'), - role=DeviceRole.objects.create(name='Device Role', slug='device-role'), - ), + Rack: Rack.objects.create(name='Rack', site=site, location=location), } def test_every_mapped_model_stashes_its_fields_on_save(self): @@ -550,50 +545,13 @@ class ScopeSignalConnectionTestCase(TestCase): self.assertEqual(device.site, self.site_b) self.assertEqual(interface._site, self.site_b) - def test_device_save_pins_queries_to_saving_connection(self): - device = Device.objects.create( - name='Device', - site=self.site_a, - device_type=self.device_type, - role=self.device_role, - ) - interface = Interface.objects.create(device=device, name='Interface 1') - device = Device.objects.get(pk=device.pk) - device.site_id = self.site_b.pk - with override_settings(DATABASE_ROUTERS=[PinnedConnectionRouter(CableTermination, Interface, Site)]): - device.save() - - interface.refresh_from_db() - self.assertEqual(interface._site, self.site_b) - - def test_site_save_pins_scope_resync_to_saving_connection(self): - region = Region.objects.create(name='Region', slug='region') - cluster_type = ClusterType.objects.create(name='Cluster Type', slug='cluster-type') - # Scope the Cluster to a Location rather than to the Site itself: the rebuild then - # has to resolve the Location behind the object's generic scope, which is the read - # that must follow the connection the Site was saved on. - location = Location.objects.create(name='Location', slug='location', site=self.site_a) - cluster = Cluster.objects.create(name='Cluster', type=cluster_type, scope=location) - - site = Site.objects.get(pk=self.site_a.pk) - site.region = region - # Region is included to catch the Location's site.region read made while rebuilding - # the cached fields; Site itself cannot be, as Django routes the save under test. - router = PinnedConnectionRouter(CircuitTermination, Cluster, Location, Prefix, Region, WirelessLAN) - with override_settings(DATABASE_ROUTERS=[router]): - site.save() - - cluster.refresh_from_db() - self.assertEqual(cluster._region, region) - - -class DeviceSiteChangeSignalTestCase(ScopePropagationCaptureMixin, TestCase): +class DeviceComponentScopeTriggerTestCase(TestCase): """ - Verify dcim.signals.handle_device_site_change propagates a Device's site/location/rack - to its components on save, and only then. + Verify the PostgreSQL trigger (dcim migration 0239) that propagates a Device's site/location/rack + onto its components' denormalized _site/_location/_rack columns. This replaces the former + dcim.signals.handle_device_site_change handler. """ - propagation_tables = COMPONENT_TABLES @classmethod def setUpTestData(cls): @@ -619,98 +577,24 @@ class DeviceSiteChangeSignalTestCase(ScopePropagationCaptureMixin, TestCase): interface.refresh_from_db() self.assertEqual(interface._site, self.site_b) - def _seed_device_with_components(self): + def test_bulk_update_of_device_updates_components_cached_scope(self): + """ + A bulk QuerySet.update() bypasses post_save (the old handler never fired for it); the DB + trigger fires regardless. This is also the path the Rack/Location cascades take. + """ device = Device.objects.create( name='Device', site=self.site_a, device_type=self.device_type, role=self.device_role, ) - Interface.objects.create(device=device, name='Interface 1') - return device + interface = Interface.objects.create(device=device, name='Interface 1') + self.assertEqual(interface._site, self.site_a) - def test_unchanged_scope_skips_propagation(self): - # Components repopulate _site/_location/_rack from their Device on their own save - # (see ComponentModel.save), so a Device save which moved the Device nowhere has - # nothing to push down and must not rewrite a single component row. - device = self._seed_device_with_components() - device.description = 'updated' + Device.objects.filter(pk=device.pk).update(site=self.site_b) - self.assertEqual(self.capture_propagation_updates(device), set()) - - def test_changed_site_propagates(self): - # Counterpart to the test above, which would pass vacuously if these UPDATEs stopped - # being issued (or their tables were renamed) rather than merely being skipped. - device = self._seed_device_with_components() - device.site = self.site_b - - self.assertEqual(self.capture_propagation_updates(device), self.propagation_tables) - - def test_changed_rack_propagates(self): - # A Rack assignment is the third guarded field, and the only one changed here: the - # Rack is deliberately left without a Location, so Device.save() does not inherit one - # and neither site nor location moves. - device = self._seed_device_with_components() - rack = Rack.objects.create(name='Rack', site=self.site_a) - self.assertIsNone(rack.location) - device.rack = rack - - self.assertEqual(self.capture_propagation_updates(device), self.propagation_tables) - - def test_raw_save_skips_propagation(self): - # raw=True is set only by Django's loaddata pathway, whose fixture already carries the - # denormalized values for every object it loads, so the propagation would rewrite each - # matched row with what it already holds. - device = self._seed_device_with_components() - device.site = self.site_b - - self.assertEqual(self.capture_propagation_updates(device, raw=True), set()) - - def test_stale_partial_save_does_not_propagate_an_unwritten_scope(self): - # As for Location and Rack: this instance was loaded before the move below, so its - # in-memory site is not one this save writes and must not reach the components. - device = self._seed_device_with_components() - stale = Device.objects.get(pk=device.pk) - - device.site = self.site_b - device.save() - - stale.description = 'updated' - self.assertEqual( - self.capture_propagation_updates(stale, update_fields=['description']), set() - ) - - self.assertEqual(Interface.objects.get(device=device)._site, self.site_b) - - def test_stale_partial_save_propagates_written_field_with_database_values(self): - # The mixed case, which the skip cannot cover: one guarded field is written, so the - # propagation must run — and the two fields the save did not write have to be taken - # from the database, not from the stale instance. Assigning the rack alone leaves the - # site and location columns untouched, so the components must end up at site_b (where - # the device actually is) rather than site_a (which the instance still carries). - device = self._seed_device_with_components() - stale = Device.objects.get(pk=device.pk) - - device.site = self.site_b - device.save() - - # A rack in site_b with no location, so Device.save() inherits no location from it. - rack = Rack.objects.create(name='Rack', site=self.site_b) - self.assertIsNone(rack.location) - stale.rack = rack - - self.assertEqual( - self.capture_propagation_updates(stale, update_fields=['rack']), - self.propagation_tables, - ) - - interface = Interface.objects.get(device=device) + interface.refresh_from_db() self.assertEqual(interface._site, self.site_b) - self.assertEqual(interface._rack, rack) - self.assertIsNone(interface._location) - # The device's own site column was never rewritten by the partial save either. - device.refresh_from_db() - self.assertEqual(device.site, self.site_b) class VirtualChassisMasterSignalTestCase(TestCase): @@ -1016,10 +900,12 @@ class MACAddressInterfaceSignalTestCase(TestCase): self.assertIsNone(mac.assigned_object) -class SyncCachedScopeFieldsSignalTestCase(TestCase): +class CachedScopeFieldTriggerTestCase(TestCase): """ - Verify dcim.signals.sync_cached_scope_fields recomputes cached scope fields on - Prefix, Cluster, and WirelessLAN when a Site or Location is modified. + Verify the PostgreSQL triggers (ipam/virtualization/wireless denormalization migrations) that keep + the CachedScopeMixin scope columns (_site/_location/_region/_site_group) on Prefix, Cluster, and + WirelessLAN in sync when a scoped Site or Location is modified. These replace the former + dcim.signals.sync_cached_scope_fields handler. """ def test_site_group_change_updates_prefix_cached_scope(self): @@ -1059,11 +945,9 @@ class SyncCachedScopeFieldsSignalTestCase(TestCase): self.assertEqual(prefix._location, location) self.assertEqual(prefix._site, site_b) - def test_signal_updates_cluster_and_wirelesslan_cached_scope(self): - # Lock down the explicit (Prefix, Cluster, WirelessLAN) tuple in the - # signal by exercising Cluster and WirelessLAN alongside Prefix. If a - # future change drops Cluster or WirelessLAN from that tuple, this test - # will catch it. + def test_triggers_update_cluster_and_wirelesslan_cached_scope(self): + # Cluster and WirelessLAN each carry their own Site/Location triggers (installed by the + # virtualization and wireless denormalization migrations); exercise both alongside Prefix. group_a = SiteGroup.objects.create(name='Group A', slug='group-a') group_b = SiteGroup.objects.create(name='Group B', slug='group-b') site = Site.objects.create(name='Site', slug='site', group=group_a) @@ -1086,392 +970,6 @@ class SyncCachedScopeFieldsSignalTestCase(TestCase): # Should not raise — newly-created sites have nothing to sync. Site.objects.create(name='New Site', slug='new-site') - def test_save_with_unchanged_scope_fields_skips_resync(self): - group = SiteGroup.objects.create(name='Group', slug='group') - site = Site.objects.create(name='Site', slug='site', group=group) - location = Location.objects.create(name='Loc', slug='loc', site=site) - prefix = Prefix.objects.create(prefix='10.0.0.0/24', scope=site) - self.assertIsNone(prefix._location) - - # Poison a cached column via a signal-less update; a skipped resync must leave it as-is. - # _location is poisoned (rather than _region/_site_group) because the denormalized-field - # registry (netbox.denormalized) unconditionally rewrites those two on every Site save, - # which would mask whether this signal ran. - Prefix.objects.filter(pk=prefix.pk).update(_location=location) - - site.description = 'updated' - site.save() - - prefix.refresh_from_db() - self.assertEqual(prefix._location, location) - - def test_location_save_with_unchanged_site_skips_resync(self): - region = Region.objects.create(name='Region', slug='region') - site = Site.objects.create(name='Site', slug='site') - location = Location.objects.create(name='Loc', slug='loc', site=site) - prefix = Prefix.objects.create(prefix='10.0.0.0/24', scope=location) - self.assertIsNone(prefix._region) - - # Poison a cached column via a signal-less update; a skipped resync must leave it as-is. - # _region is poisoned because the denormalized-field registry unconditionally rewrites - # _site on every Location save, which would mask whether this signal ran. - Prefix.objects.filter(pk=prefix.pk).update(_region=region) - - location.description = 'updated' - location.save() - - prefix.refresh_from_db() - self.assertEqual(prefix._region, region) - - def test_save_with_changed_site_group_resyncs(self): - group_a = SiteGroup.objects.create(name='Group A', slug='group-a') - group_b = SiteGroup.objects.create(name='Group B', slug='group-b') - site = Site.objects.create(name='Site', slug='site', group=group_a) - prefix = Prefix.objects.create(prefix='10.0.0.0/24', scope=site) - self.assertEqual(prefix._site_group, group_a) - - site.group = group_b - site.save() - - prefix.refresh_from_db() - self.assertEqual(prefix._site, site) - self.assertEqual(prefix._site_group, group_b) - self.assertIsNone(prefix._region) - self.assertIsNone(prefix._location) - - def test_save_with_changed_region_resyncs(self): - region_a = Region.objects.create(name='Region A', slug='region-a') - region_b = Region.objects.create(name='Region B', slug='region-b') - site = Site.objects.create(name='Site', slug='site', region=region_a) - prefix = Prefix.objects.create(prefix='10.0.0.0/24', scope=site) - self.assertEqual(prefix._region, region_a) - - site.region = region_b - site.save() - - prefix.refresh_from_db() - self.assertEqual(prefix._region, region_b) - self.assertEqual(prefix._site, site) - - # A region-to-None transition must also resync. - site.region = None - site.save() - - prefix.refresh_from_db() - self.assertIsNone(prefix._region) - self.assertEqual(prefix._site, site) - - def test_location_save_with_changed_site_resyncs(self): - site_a = Site.objects.create(name='Site A', slug='site-a') - site_b = Site.objects.create(name='Site B', slug='site-b') - location = Location.objects.create(name='Loc', slug='loc', site=site_a) - prefix = Prefix.objects.create(prefix='10.0.0.0/24', scope=location) - self.assertEqual(prefix._site, site_a) - - location.site = site_b - location.save() - - prefix.refresh_from_db() - self.assertEqual(prefix._site, site_b) - self.assertEqual(prefix._location, location) - - def test_noop_save_skips_resync(self): - site = Site.objects.create(name='Site', slug='site') - location = Location.objects.create(name='Loc', slug='loc', site=site) - prefix = Prefix.objects.create(prefix='10.0.0.0/24', scope=site) - - # Poison a cached column, then save the Site with no field changes. The resync is - # skipped: the pre-save values read from the database match what is being written. - # Stale cached values are prevented at their source (handle_location_site_change - # repairs descendant-scoped objects), so a no-op save no longer doubles as a - # repair mechanism. _location is poisoned because only this signal (not the - # denormalized-field registry) could repair it on a Site save. - Prefix.objects.filter(pk=prefix.pk).update(_location=location) - - site.save() - - prefix.refresh_from_db() - self.assertEqual(prefix._location, location) - - def test_location_site_change_updates_descendant_scoped_caches(self): - group_a = SiteGroup.objects.create(name='Group A', slug='group-a') - group_b = SiteGroup.objects.create(name='Group B', slug='group-b') - site_a = Site.objects.create(name='Site A', slug='site-a', group=group_a) - site_b = Site.objects.create(name='Site B', slug='site-b', group=group_b) - parent = Location.objects.create(name='Parent', slug='parent', site=site_a) - child = Location.objects.create(name='Child', slug='child', site=site_a, parent=parent) - prefix = Prefix.objects.create(prefix='10.0.0.0/24', scope=child) - cluster_type = ClusterType.objects.create(name='CT', slug='ct') - cluster = Cluster.objects.create(name='Cluster', type=cluster_type, scope=child) - self.assertEqual(prefix._site, site_a) - self.assertEqual(cluster._site, site_a) - - # Moving the parent Location drags the child along via a signal-less queryset - # update, so no post_save fires for the child. The cached scope fields of objects - # scoped to descendant locations must be updated in the same handler. - parent.site = site_b - parent.save() - - child.refresh_from_db() - self.assertEqual(child.site, site_b) - prefix.refresh_from_db() - self.assertEqual(prefix._site, site_b) - self.assertEqual(prefix._site_group, group_b) - self.assertEqual(prefix._location, child) - cluster.refresh_from_db() - self.assertEqual(cluster._site, site_b) - self.assertEqual(cluster._site_group, group_b) - - def test_location_site_change_repairs_descendant_row_with_poisoned_location_cache(self): - site_a = Site.objects.create(name='Site A', slug='site-a') - site_b = Site.objects.create(name='Site B', slug='site-b') - parent = Location.objects.create(name='Parent', slug='parent', site=site_a) - child = Location.objects.create(name='Child', slug='child', site=site_a, parent=parent) - other = Location.objects.create(name='Other', slug='other', site=site_a) - prefix = Prefix.objects.create(prefix='10.0.0.0/24', scope=child) - - # Poison the cached _location to point outside the subtree. The repair must select - # rows through the authoritative scope (scope_type/scope_id), not the cached - # column, or this descendant-scoped row is missed — and it must repair _location - # itself, or the row stays invisible to future saves of its real location. - Prefix.objects.filter(pk=prefix.pk).update(_location=other) - - # Re-fetch: creating further Locations renumbers the MPTT tree, and the in-memory - # instance's stale bounds would otherwise select the wrong subtree (a pre-existing - # handler hazard, tracked separately). - parent = Location.objects.get(pk=parent.pk) - parent.site = site_b - parent.save() - - prefix.refresh_from_db() - self.assertEqual(prefix._site, site_b) - self.assertEqual(prefix._location, child) - - def test_location_site_change_ignores_foreign_row_with_poisoned_location_cache(self): - site_a = Site.objects.create(name='Site A', slug='site-a') - site_b = Site.objects.create(name='Site B', slug='site-b') - site_c = Site.objects.create(name='Site C', slug='site-c') - parent = Location.objects.create(name='Parent', slug='parent', site=site_a) - child = Location.objects.create(name='Child', slug='child', site=site_a, parent=parent) - elsewhere = Location.objects.create(name='Elsewhere', slug='elsewhere', site=site_c) - prefix = Prefix.objects.create(prefix='10.0.0.0/24', scope=elsewhere) - - # Poison the cached _location to point INTO the subtree being moved. A repair that - # selects rows through the cached column would wrongly stamp this unrelated object - # with the destination site. - Prefix.objects.filter(pk=prefix.pk).update(_location=child) - - # Re-fetch to avoid stale in-memory MPTT bounds (see the sibling test). - parent = Location.objects.get(pk=parent.pk) - parent.site = site_b - parent.save() - - prefix.refresh_from_db() - self.assertEqual(prefix._site, site_c) - # The poisoned _location is intentional residue: this row is not scoped to the - # moved subtree, so only a save touching its own scope chain repairs it. - self.assertEqual(prefix._location, child) - - def test_resync_recomputes_from_scope_not_from_saved_instance(self): - group_a = SiteGroup.objects.create(name='Group A', slug='group-a') - group_b = SiteGroup.objects.create(name='Group B', slug='group-b') - group_c = SiteGroup.objects.create(name='Group C', slug='group-c') - site_a = Site.objects.create(name='Site A', slug='site-a', group=group_a) - site_b = Site.objects.create(name='Site B', slug='site-b', group=group_b) - location = Location.objects.create(name='Loc', slug='loc', site=site_b) - prefix = Prefix.objects.create(prefix='10.0.0.0/24', scope=location) - self.assertEqual(prefix._site, site_b) - - # Fabricate a stale row pointing at the wrong site, then trigger site_a's resync - # with a real scope change. The row matches site_a's rebuild filter (_site=site_a) - # but its actual scope lives under site_b: recomputed values must derive from the - # row's scope, never be stamped from the saved instance. - Prefix.objects.filter(pk=prefix.pk).update(_site=site_a, _site_group=group_a) - - site_a.group = group_c - site_a.save() - - prefix.refresh_from_db() - self.assertEqual(prefix._site, site_b) - self.assertEqual(prefix._site_group, group_b) - self.assertEqual(prefix._location, location) - - def test_stale_save_after_concurrent_update_resyncs(self): - """ - A stale full save can write an old scope value back over a concurrent update - (when the client sends no If-Match header). The resync must run so the caches follow - whatever was actually written. A Cluster is used because it has no - denormalized-field registration to mask a skipped resync (unlike Prefix). The - lock-wait variant of this race (the concurrent update not yet committed) cannot - be exercised in a single-connection TestCase. - """ - region_a = Region.objects.create(name='Region A', slug='region-a') - region_b = Region.objects.create(name='Region B', slug='region-b') - site = Site.objects.create(name='Site', slug='site', region=region_a) - cluster_type = ClusterType.objects.create(name='CT', slug='ct') - cluster = Cluster.objects.create(name='Cluster', type=cluster_type, scope=site) - - # Client 1 loads the site (and, like every request-driven save, snapshots it). - s1 = Site.objects.get(pk=site.pk) - s1.snapshot() - - # Client 2 changes the region and saves; caches follow. - s2 = Site.objects.get(pk=site.pk) - s2.region = region_b - s2.save() - cluster.refresh_from_db() - self.assertEqual(cluster._region, region_b) - - # Client 1's stale save writes region_a back. From client 1's point of view - # nothing changed, but the database value did: the resync must run. - s1.save() - - cluster.refresh_from_db() - self.assertEqual(cluster._region, region_a) - - def test_resync_groups_updates_by_distinct_scope(self): - group_a = SiteGroup.objects.create(name='Group A', slug='group-a') - group_b = SiteGroup.objects.create(name='Group B', slug='group-b') - site = Site.objects.create(name='Site', slug='site', group=group_a) - location = Location.objects.create(name='Loc', slug='loc', site=site) - prefix_1 = Prefix.objects.create(prefix='10.0.1.0/24', scope=site) - prefix_2 = Prefix.objects.create(prefix='10.0.2.0/24', scope=site) - prefix_3 = Prefix.objects.create(prefix='10.0.3.0/24', scope=location) - cluster_type = ClusterType.objects.create(name='CT', slug='ct') - cluster = Cluster.objects.create(name='Cluster', type=cluster_type, scope=site) - - site.group = group_b - site.save() - - # Each object's cached fields must derive from its own scope; a resync that - # applies one scope's computed values to all matched rows would corrupt these. - for prefix in (prefix_1, prefix_2): - prefix.refresh_from_db() - self.assertIsNone(prefix._location) - self.assertEqual(prefix._site, site) - self.assertEqual(prefix._site_group, group_b) - prefix_3.refresh_from_db() - self.assertEqual(prefix_3._location, location) - self.assertEqual(prefix_3._site, site) - self.assertEqual(prefix_3._site_group, group_b) - cluster.refresh_from_db() - self.assertEqual(cluster._site_group, group_b) - - def test_resync_issues_one_update_per_distinct_scope(self): - group = SiteGroup.objects.create(name='Group', slug='group') - site = Site.objects.create(name='Site', slug='site') - location = Location.objects.create(name='Loc', slug='loc', site=site) - for i in range(1, 4): - Prefix.objects.create(prefix=f'10.0.{i}.0/24', scope=site) - for i in range(4, 6): - Prefix.objects.create(prefix=f'10.0.{i}.0/24', scope=location) - cluster_type = ClusterType.objects.create(name='CT', slug='ct') - Cluster.objects.create(name='Cluster 1', type=cluster_type, scope=site) - Cluster.objects.create(name='Cluster 2', type=cluster_type, scope=site) - - # One UPDATE per distinct (scope_type, scope) pair — not one per row (nor a single - # per-row CASE WHEN statement). Multiple rows per scope are seeded above so that a - # default-ordering leak into DISTINCT (which degrades grouping to one pair per row) - # changes these counts. Only this signal's UPDATEs set _location_id; the - # denormalized-field registry (netbox.denormalized) also updates Prefix on every - # Site save but touches only _region_id/_site_group_id, so the filter below - # excludes it. - distinct_prefix_scopes = len(set( - Prefix.objects.filter(_site=site).values_list('scope_type_id', 'scope_id') - )) - self.assertEqual(distinct_prefix_scopes, 2) - - site.group = group # A real scope change: the resync must run - - with CaptureQueriesContext(connection) as ctx: - site.save() - - prefix_updates = [ - q for q in ctx.captured_queries - if q['sql'].startswith('UPDATE "ipam_prefix"') and '"_location_id"' in q['sql'] - ] - self.assertEqual(len(prefix_updates), distinct_prefix_scopes) - cluster_updates = [ - q for q in ctx.captured_queries if q['sql'].startswith('UPDATE "virtualization_cluster"') - ] - self.assertEqual(len(cluster_updates), 1) - - def test_stale_partial_save_skips_resync(self): - # A save passing update_fields writes only the fields it names, so an omitted scope - # field cannot have changed and the rebuild has nothing to recompute. - group_a = SiteGroup.objects.create(name='Group A', slug='group-a') - group_b = SiteGroup.objects.create(name='Group B', slug='group-b') - site = Site.objects.create(name='Site', slug='site', group=group_a) - cluster_type = ClusterType.objects.create(name='CT', slug='ct') - cluster = Cluster.objects.create(name='Cluster', type=cluster_type, scope=site) - - stale = Site.objects.get(pk=site.pk) - site.group = group_b - site.save() - - stale.description = 'updated' - with CaptureQueriesContext(connection) as ctx: - stale.save(update_fields=['description']) - - self.assertEqual( - [q for q in ctx.captured_queries if q['sql'].startswith('UPDATE "virtualization_cluster"')], - [], - ) - cluster.refresh_from_db() - self.assertEqual(cluster._site_group, group_b) - - def test_raw_save_skips_resync(self): - # raw=True is set only by Django's loaddata pathway, whose fixture already carries the - # cached scope fields for every object it loads, so the rebuild would recompute the - # values the rows already hold. - group_a = SiteGroup.objects.create(name='Group A', slug='group-a') - group_b = SiteGroup.objects.create(name='Group B', slug='group-b') - site = Site.objects.create(name='Site', slug='site', group=group_a) - cluster_type = ClusterType.objects.create(name='CT', slug='ct') - Cluster.objects.create(name='Cluster', type=cluster_type, scope=site) - - site.group = group_b # A real scope change, which a non-raw save would resync - - with CaptureQueriesContext(connection) as ctx: - site.save_base(raw=True) - - self.assertEqual( - [q for q in ctx.captured_queries if q['sql'].startswith('UPDATE "virtualization_cluster"')], - [], - ) - - -class SyncCachedScopeFieldsAutocommitTestCase(TransactionTestCase): - """ - Exercise the autocommit save path, which TestCase cannot reach (it wraps every test - in a transaction). Outside an atomic block the pre-save read and the save's UPDATE - run in separate transactions, so the skip guard is disabled there: the stash is - cleared and the rebuild runs unconditionally. - - Note: TransactionTestCase teardown flushes all tables, which removes rows seeded by - data migrations from a --keepdb database (e.g. the dcim.0206 ModuleTypeProfiles). - A fresh test database restores them. - """ - - def test_autocommit_noop_save_always_resyncs(self): - group = SiteGroup.objects.create(name='Group', slug='group') - site = Site.objects.create(name='Site', slug='site', group=group) - location = Location.objects.create(name='Loc', slug='loc', site=site) - prefix = Prefix.objects.create(prefix='10.0.0.0/24', scope=site) - - # A transactional save first, so the instance carries a stash. The subsequent - # autocommit save must clear it rather than compare against a previous save's - # values. - with transaction.atomic(): - site.save() - - Prefix.objects.filter(pk=prefix.pk).update(_location=location) - - site.save() # Autocommit: no stash, unconditional rebuild - - prefix.refresh_from_db() - self.assertIsNone(prefix._location) - class CableSignalDirectHandlerTestCase(SimpleTestCase): """ @@ -1503,3 +1001,113 @@ class CableSignalDirectHandlerTestCase(SimpleTestCase): signals.update_mac_address_interface(instance=interface, created=True, raw=True) primary_mac.save.assert_not_called() + + +class CableTerminationDenormalizationTriggerTestCase(TestCase): + """ + Verify the PostgreSQL triggers (installed by dcim migration 0239) that keep a + CableTermination's denormalized _device/_rack/_location/_site columns in sync with the + parent Device/Rack/Location. + + These replace the former Python `post_save` handler in netbox.denormalized. Crucially, + the triggers also fire for bulk QuerySet.update() writes — which the handler (a post_save + receiver) never saw — so this exercises that path explicitly. + """ + + @classmethod + def setUpTestData(cls): + cls.site_a = Site.objects.create(name='Site A', slug='site-a') + cls.site_b = Site.objects.create(name='Site B', slug='site-b') + cls.location_b = Location.objects.create(name='Loc B', slug='loc-b', site=cls.site_b) + cls.rack_b = Rack.objects.create(name='Rack B', site=cls.site_b, location=cls.location_b) + manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer') + cls.device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type') + cls.device_role = DeviceRole.objects.create(name='Device Role', slug='device-role') + + def _connected_termination(self): + device = Device.objects.create( + name='Device', site=self.site_a, device_type=self.device_type, role=self.device_role, + ) + interface_a = Interface.objects.create(device=device, name='Interface A') + interface_b = Interface.objects.create(device=device, name='Interface B') + cable = Cable(a_terminations=[interface_a], b_terminations=[interface_b]) + cable.save() + termination = CableTermination.objects.filter(_device=device).first() + self.assertIsNotNone(termination) + self.assertEqual(termination._site, self.site_a) + return device, termination + + def test_device_move_propagates_to_cable_termination(self): + device, termination = self._connected_termination() + + device.site = self.site_b + device.location = self.location_b + device.rack = self.rack_b + device.save() + + termination.refresh_from_db() + self.assertEqual(termination._site, self.site_b) + self.assertEqual(termination._location, self.location_b) + self.assertEqual(termination._rack, self.rack_b) + + def test_bulk_update_of_device_propagates_to_cable_termination(self): + """ + A bulk QuerySet.update() bypasses post_save (the old handler never fired for it); + the DB trigger fires regardless. + """ + device, termination = self._connected_termination() + + Device.objects.filter(pk=device.pk).update(site=self.site_b) + + termination.refresh_from_db() + self.assertEqual(termination._site, self.site_b) + + +def _concrete_subclasses(base): + """ + Yield every non-abstract, non-plugin model descending from an abstract base model. Plugin-contributed + models are skipped: a plugin that adds a ComponentModel/CachedScopeMixin subclass is responsible for + its own trigger migration, and must not fail core's coverage check just by being installed. + """ + for subclass in base.__subclasses__(): + if subclass._meta.abstract: + yield from _concrete_subclasses(subclass) + elif not isinstance(apps.get_app_config(subclass._meta.app_label), PluginConfig): + yield subclass + + +def _installed_triggers(): + with connection.cursor() as cursor: + cursor.execute('SELECT tgname FROM pg_trigger WHERE NOT tgisinternal') + return {row[0] for row in cursor.fetchall()} + + +class DenormalizationTriggerCoverageTestCase(TestCase): + """ + Guard against a new core model silently shipping without its denormalization triggers. The set of + device-component tables and CachedScopeMixin dependents is hand-listed in migrations; this test + derives those sets from the model layer and asserts the expected triggers are installed, so adding + a new component / scoped model without a matching trigger migration fails CI. Plugin-contributed + models are excluded (see _concrete_subclasses). + """ + + def test_device_components_have_device_trigger(self): + triggers = _installed_triggers() + for model in _concrete_subclasses(ComponentModel): + table = model._meta.db_table + self.assertIn( + f'{table}_denorm_from_dcim_device', triggers, + msg=f'{model.__name__} has no dcim_device denormalization trigger (add it to ' + f'dcim migration 0239 COMPONENT_TABLES)', + ) + + def test_cached_scope_models_have_site_and_location_triggers(self): + triggers = _installed_triggers() + for model in _concrete_subclasses(CachedScopeMixin): + table = model._meta.db_table + for source in ('dcim_site', 'dcim_location'): + self.assertIn( + f'{table}_denorm_from_{source}', triggers, + msg=f'{model.__name__} (CachedScopeMixin) has no {source} denormalization trigger; ' + f'add cached_scope_triggers({table!r}) in a migration for its app', + ) diff --git a/netbox/dcim/tests/test_tables.py b/netbox/dcim/tests/test_tables.py index b21ab59c3..e66d5b2e7 100644 --- a/netbox/dcim/tests/test_tables.py +++ b/netbox/dcim/tests/test_tables.py @@ -1,4 +1,15 @@ -from dcim.models import ConsolePort, Interface, PowerPort +from dcim.choices import CableEndChoices, CableProfileChoices, InterfaceTypeChoices +from dcim.models import ( + Cable, + ConsolePort, + Device, + DeviceRole, + DeviceType, + Interface, + Manufacturer, + PowerPort, + Site, +) from dcim.tables import * from utilities.testing import TableTestCases @@ -67,6 +78,10 @@ class ModuleTypeProfileTableTestCase(TableTestCases.StandardTableTestCase): table = ModuleTypeProfileTable +class ModuleBayTypeTableTestCase(TableTestCases.StandardTableTestCase): + table = ModuleBayTypeTable + + class ModuleTypeTableTestCase(TableTestCases.StandardTableTestCase): table = ModuleTypeTable @@ -174,6 +189,92 @@ class InterfaceConnectionTableTestCase(TableTestCases.StandardTableTestCase): class CableTableTestCase(TableTestCases.StandardTableTestCase): table = CableTable + @staticmethod + def _create_device(name): + site = Site.objects.get_or_create(name='Site 1', slug='site-1')[0] + manufacturer = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1')[0] + device_type = DeviceType.objects.get_or_create(model='Device Type 1', manufacturer=manufacturer)[0] + role = DeviceRole.objects.get_or_create(name='Device Role 1', slug='device-role-1')[0] + + return Device.objects.create(name=name, site=site, device_type=device_type, role=role) + + def test_termination_columns_follow_connector_order(self): + """Termination & parent columns must render in connector order, not an arbitrary one.""" + site = Site.objects.create(name='Site 1', slug='site-1') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer) + role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + + switch = Device.objects.create(name='switch1', site=site, device_type=device_type, role=role) + uplink = Interface.objects.create( + device=switch, name='et-0/0/0', type=InterfaceTypeChoices.TYPE_100GE_QSFP28 + ) + # Create the servers in ascending order, then cable them in descending order, so that + # connector order and primary key order disagree. + servers = [ + Device.objects.create(name=f'server{i}', site=site, device_type=device_type, role=role) + for i in range(1, 5) + ] + interfaces = [ + Interface.objects.create(device=device, name='eth0', type=InterfaceTypeChoices.TYPE_25GE_SFP28) + for device in servers + ] + cable = Cable( + a_terminations=[uplink], + b_terminations=list(reversed(interfaces)), + profile=CableProfileChoices.BREAKOUT_1C4P_4C1P, + ) + cable.save() + + table = CableTable(Cable.objects.filter(pk=cable.pk)) + table.columns.show('device_b') + row = list(table.rows)[0] + + self.assertEqual(row.get_cell_value('device_b'), 'server4,server3,server2,server1') + self.assertEqual( + [ct.termination for ct in cable.terminations.filter(cable_end=CableEndChoices.SIDE_B)], + list(reversed(interfaces)) + ) + + def test_parent_columns_are_not_deduplicated_for_export(self): + """ + A parent column must export one value per termination so that the exported data can be + re-imported, but collapse repeated values when rendered. + """ + switch = self._create_device('switch1') + servers = [self._create_device(f'server{i}') for i in range(1, 3)] + uplinks = [ + Interface.objects.create( + device=switch, name=f'et-0/0/{i}', type=InterfaceTypeChoices.TYPE_100GE_QSFP28 + ) + for i in range(4) + ] + interfaces = [ + Interface.objects.create(device=device, name=name, type=InterfaceTypeChoices.TYPE_25GE_SFP28) + for device in servers for name in ('eth0', 'eth1') + ] + cable = Cable( + a_terminations=uplinks, + b_terminations=interfaces, + profile=CableProfileChoices.TRUNK_4C1P, + ) + cable.save() + + table = CableTable(Cable.objects.filter(pk=cable.pk)) + table.columns.show('device_a') + table.columns.show('device_b') + row = list(table.rows)[0] + + # Exported values include one parent per termination + self.assertEqual(row.get_cell_value('a_terminations'), 'et-0/0/0,et-0/0/1,et-0/0/2,et-0/0/3') + self.assertEqual(row.get_cell_value('device_a'), 'switch1,switch1,switch1,switch1') + self.assertEqual(row.get_cell_value('b_terminations'), 'eth0,eth1,eth0,eth1') + self.assertEqual(row.get_cell_value('device_b'), 'server1,server1,server2,server2') + + # Rendered values collapse repeated parents + self.assertEqual(row.get_cell('device_a').count(' with the Set as primary action riding it via + formaction, and no nested . This fails if the list view stops wrapping the table in + a form or the column's context detection breaks (the class of regression #18821 was). + """ + self.add_permissions('dcim.view_macaddress') + mac = MACAddress.objects.filter(assigned_object_id__isnull=False).first() + list_url = reverse('dcim:macaddress_list') + set_primary_url = reverse('dcim:macaddress_set_primary', kwargs={'pk': mac.pk}) + action_url = f'{set_primary_url}?return_url={quote(list_url)}' + + response = self.client.get(list_url) + self.assertHttpStatus(response, 200) + content = response.content.decode() + + # The action rides the bulk form via a formaction button; it injects no nested of its + # own (which the parser would drop, producing the original 405). + self.assertInHTML( + f'', + content, + ) + self.assertNotIn(f' (no surrounding form to ride) that returns the user to that object. + """ + self.add_permissions('dcim.view_macaddress') + mac = MACAddress.objects.filter(assigned_object_id__isnull=False).first() + interface_url = mac.assigned_object.get_absolute_url() + set_primary_url = reverse('dcim:macaddress_set_primary', kwargs={'pk': mac.pk}) + action_url = f'{set_primary_url}?return_url={quote(interface_url)}' + + response = self.client.get( + reverse('dcim:macaddress_list') + f'?embedded=True&return_url={quote(interface_url)}', + headers={'hx-request': 'true'}, + ) + self.assertHttpStatus(response, 200) + content = response.content.decode() + + # A self-contained POST to the returning action URL (valid here, no surrounding form) + # wraps the submit button. Assert the button structurally; the form's action carries the + # return_url so the user lands back on the interface. + self.assertInHTML( + '', + content, + ) + self.assertIn(f'', content) + + @tag('regression') # Issue #18821 + def test_set_primary_from_embedded_redirects_to_interface(self): + """ + A set-primary POST with no return_url falls back to the assigned object's detail page, so + the action always lands the user on the interface even absent an explicit return target. + """ + self.add_permissions('dcim.view_macaddress', 'dcim.change_interface') + mac = MACAddress.objects.filter(assigned_object_id__isnull=False).first() + interface = mac.assigned_object + + url = reverse('dcim:macaddress_set_primary', kwargs={'pk': mac.pk}) + response = self.client.post(url) + + self.assertHttpStatus(response, 302) + self.assertEqual(response['Location'], interface.get_absolute_url()) + @tag('regression') # Issue #20542 def test_create_macaddress_via_quickadd(self): """ - Test creating a MAC address via quick-add modal (e.g., from Interface form). + Test creating a MAC address via the quick-add modal mechanism. Regression test for issue #20542 where form prefix was missing in POST handler. """ self.add_permissions('dcim.view_macaddress', 'dcim.view_interface', 'extras.view_tag') diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index a75ed608f..b72439782 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -4,6 +4,30 @@ from django.utils.translation import gettext_lazy as _ from netbox.ui import actions, attrs, panels +class BayTypeIncompatibilityPanel(panels.Panel): + """ + Renders a warning banner when a Module is incompatibly installed (its type's bay type set and + the bay's bay type set are both non-empty and share no common members). + Silently omitted when the installation is compatible or unconstrained. + """ + template_name = 'dcim/panels/bay_type_incompatibility.html' + + def should_render(self, context): + from dcim.models import Module, ModuleBay + obj = context.get('object') + if isinstance(obj, Module): + return not obj.is_bay_compatible + if isinstance(obj, ModuleBay): + return not obj.is_module_compatible + return False + + def get_context(self, context): + from dcim.models import Module + ctx = super().get_context(context) + ctx['is_module_view'] = isinstance(context.get('object'), Module) + return ctx + + class SitePanel(panels.ObjectAttributesPanel): region = attrs.NestedObjectAttr('region', linkify=True) group = attrs.NestedObjectAttr('group', linkify=True) @@ -55,6 +79,8 @@ class RackPanel(panels.ObjectAttributesPanel): serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True) asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True) airflow = attrs.ChoiceAttr('airflow') + cooling_capability = attrs.ChoiceAttr('cooling_capability') + cooling_capacity = attrs.TextAttr('cooling_capacity', format_string=_('{} kW')) space_utilization = attrs.UtilizationAttr('get_utilization') power_utilization = attrs.UtilizationAttr('get_power_utilization') @@ -82,6 +108,8 @@ class RackTypePanel(panels.ObjectAttributesPanel): manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True) model = attrs.TextAttr('model') description = attrs.TextAttr('description') + cooling_capability = attrs.ChoiceAttr('cooling_capability') + cooling_capacity = attrs.TextAttr('cooling_capacity', format_string=_('{} kW')) class DevicePanel(panels.ObjectAttributesPanel): @@ -94,6 +122,7 @@ class DevicePanel(panels.ObjectAttributesPanel): gps_coordinates = attrs.GPSCoordinatesAttr() tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group') description = attrs.TextAttr('description') + cooling_method = attrs.ChoiceAttr('cooling_method') airflow = attrs.ChoiceAttr('airflow') serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True) asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True) @@ -157,7 +186,9 @@ class DeviceTypePanel(panels.ObjectAttributesPanel): full_depth = attrs.BooleanAttr('is_full_depth') weight = attrs.WeightAttr('weight') subdevice_role = attrs.ChoiceAttr('subdevice_role', label=_('Parent/child')) + cooling_method = attrs.ChoiceAttr('cooling_method') airflow = attrs.ChoiceAttr('airflow') + end_of_life = attrs.DateTimeAttr('end_of_life', spec='date') front_image = attrs.ImageAttr('front_image') rear_image = attrs.ImageAttr('rear_image') @@ -172,6 +203,14 @@ class ModulePanel(panels.ObjectAttributesPanel): asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True) +class ModuleBayTypePanel(panels.ObjectAttributesPanel): + manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True) + name = attrs.TextAttr('name') + color = attrs.ColorAttr('color') + description = attrs.TextAttr('description') + module_types = attrs.RelatedObjectListAttr('module_types', label=_('Compatible Module Types'), linkify=True) + + class ModuleTypeProfilePanel(panels.ObjectAttributesPanel): name = attrs.TextAttr('name') description = attrs.TextAttr('description') @@ -183,8 +222,13 @@ class ModuleTypePanel(panels.ObjectAttributesPanel): model = attrs.TextAttr('model', label=_('Model name')) part_number = attrs.TextAttr('part_number') description = attrs.TextAttr('description') + cooling_method = attrs.ChoiceAttr('cooling_method') airflow = attrs.ChoiceAttr('airflow') weight = attrs.WeightAttr('weight') + end_of_life = attrs.DateTimeAttr('end_of_life', spec='date') + module_bay_types = attrs.RelatedObjectListAttr( + 'module_bay_types', label=_('Bay Type Compatibility'), linkify=True + ) class PlatformPanel(panels.NestedGroupObjectPanel): @@ -236,6 +280,29 @@ class PowerOutletPanel(panels.ObjectAttributesPanel): feed_leg = attrs.ChoiceAttr('feed_leg') +class CoolingIntakePanel(panels.ObjectAttributesPanel): + device = attrs.RelatedObjectAttr('device', linkify=True) + module = attrs.RelatedObjectAttr('module', linkify=True) + name = attrs.TextAttr('name') + label = attrs.TextAttr('label') + type = attrs.ChoiceAttr('type') + diameter = attrs.DiameterAttr('diameter') + max_flow = attrs.FlowRateAttr('max_flow') + cooling_outflow = attrs.RelatedObjectAttr('cooling_outflow', linkify=True) + description = attrs.TextAttr('description') + + +class CoolingOutflowPanel(panels.ObjectAttributesPanel): + device = attrs.RelatedObjectAttr('device', linkify=True) + module = attrs.RelatedObjectAttr('module', linkify=True) + name = attrs.TextAttr('name') + label = attrs.TextAttr('label') + type = attrs.ChoiceAttr('type') + diameter = attrs.DiameterAttr('diameter') + cooling_intake = attrs.RelatedObjectAttr('cooling_intake', linkify=True) + description = attrs.TextAttr('description') + + class FrontPortPanel(panels.ObjectAttributesPanel): device = attrs.RelatedObjectAttr('device', linkify=True) module = attrs.RelatedObjectAttr('module', linkify=True) @@ -265,6 +332,7 @@ class ModuleBayPanel(panels.ObjectAttributesPanel): label = attrs.TextAttr('label') position = attrs.TextAttr('position') description = attrs.TextAttr('description') + module_bay_types = attrs.RelatedObjectListAttr('module_bay_types', label=_('Bay Type Compatibility'), linkify=True) class InstalledModulePanel(panels.ObjectAttributesPanel): @@ -361,6 +429,31 @@ class PowerFeedElectricalPanel(panels.ObjectAttributesPanel): max_utilization = attrs.TextAttr('max_utilization', format_string='{}%') +class CoolingSourcePanel(panels.ObjectAttributesPanel): + site = attrs.RelatedObjectAttr('site', linkify=True) + location = attrs.NestedObjectAttr('location', linkify=True) + type = attrs.ChoiceAttr('type') + status = attrs.ChoiceAttr('status') + fluid_type = attrs.ChoiceAttr('fluid_type') + cooling_capacity = attrs.TextAttr('cooling_capacity', format_string=_('{} kW')) + description = attrs.TextAttr('description') + + +class CoolingFeedPanel(panels.ObjectAttributesPanel): + cooling_source = attrs.RelatedObjectAttr('cooling_source', linkify=True) + rack = attrs.RelatedObjectAttr('rack', linkify=True) + status = attrs.ChoiceAttr('status') + description = attrs.TextAttr('description') + tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group') + + +class CoolingFeedCharacteristicsPanel(panels.ObjectAttributesPanel): + title = _('Cooling Characteristics') + + cooling_capacity = attrs.TextAttr('cooling_capacity', format_string=_('{} kW')) + max_flow = attrs.FlowRateAttr('max_flow') + + class VirtualDeviceContextPanel(panels.ObjectAttributesPanel): name = attrs.TextAttr('name') device = attrs.RelatedObjectAttr('device', linkify=True) @@ -485,6 +578,7 @@ class InterfacePanel(panels.ObjectAttributesPanel): name = attrs.TextAttr('name') label = attrs.TextAttr('label') type = attrs.ChoiceAttr('type') + channels = attrs.NumericAttr('channels') speed = attrs.TemplatedAttr('speed', template_name='dcim/interface/attrs/speed.html', label=_('Speed')) duplex = attrs.ChoiceAttr('duplex') mtu = attrs.NumericAttr('mtu', label=_('MTU')) @@ -505,6 +599,7 @@ class RelatedInterfacesPanel(panels.ObjectAttributesPanel): title = _('Related Interfaces') parent = attrs.RelatedObjectAttr('parent', linkify=True) + channel_id = attrs.NumericAttr('channel_id', label=_('Channel ID')) bridge = attrs.RelatedObjectAttr('bridge', linkify=True) lag = attrs.RelatedObjectAttr('lag', linkify=True, label=_('LAG')) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 86cf79924..0e0e9eda4 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -41,6 +41,9 @@ urlpatterns = [ path('device-types/', include(get_model_urls('dcim', 'devicetype', detail=False))), path('device-types//', include(get_model_urls('dcim', 'devicetype'))), + path('module-bay-types/', include(get_model_urls('dcim', 'modulebaytype', detail=False))), + path('module-bay-types//', include(get_model_urls('dcim', 'modulebaytype'))), + path('module-type-profiles/', include(get_model_urls('dcim', 'moduletypeprofile', detail=False))), path('module-type-profiles//', include(get_model_urls('dcim', 'moduletypeprofile'))), @@ -59,6 +62,12 @@ urlpatterns = [ path('power-outlet-templates/', include(get_model_urls('dcim', 'poweroutlettemplate', detail=False))), path('power-outlet-templates//', include(get_model_urls('dcim', 'poweroutlettemplate'))), + path('cooling-intake-templates/', include(get_model_urls('dcim', 'coolingintaketemplate', detail=False))), + path('cooling-intake-templates//', include(get_model_urls('dcim', 'coolingintaketemplate'))), + + path('cooling-outflow-templates/', include(get_model_urls('dcim', 'coolingoutflowtemplate', detail=False))), + path('cooling-outflow-templates//', include(get_model_urls('dcim', 'coolingoutflowtemplate'))), + path('interface-templates/', include(get_model_urls('dcim', 'interfacetemplate', detail=False))), path('interface-templates//', include(get_model_urls('dcim', 'interfacetemplate'))), @@ -120,6 +129,22 @@ urlpatterns = [ name='device_bulk_add_poweroutlet' ), + path('cooling-intakes/', include(get_model_urls('dcim', 'coolingintake', detail=False))), + path('cooling-intakes//', include(get_model_urls('dcim', 'coolingintake'))), + path( + 'devices/cooling-intakes/add/', + views.DeviceBulkAddCoolingIntakeView.as_view(), + name='device_bulk_add_coolingintake' + ), + + path('cooling-outflows/', include(get_model_urls('dcim', 'coolingoutflow', detail=False))), + path('cooling-outflows//', include(get_model_urls('dcim', 'coolingoutflow'))), + path( + 'devices/cooling-outflows/add/', + views.DeviceBulkAddCoolingOutflowView.as_view(), + name='device_bulk_add_coolingoutflow' + ), + path('interfaces/', include(get_model_urls('dcim', 'interface', detail=False))), path('interfaces//', include(get_model_urls('dcim', 'interface'))), path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'), @@ -175,6 +200,12 @@ urlpatterns = [ path('power-feeds/', include(get_model_urls('dcim', 'powerfeed', detail=False))), path('power-feeds//', include(get_model_urls('dcim', 'powerfeed'))), + path('cooling-sources/', include(get_model_urls('dcim', 'coolingsource', detail=False))), + path('cooling-sources//', include(get_model_urls('dcim', 'coolingsource'))), + + path('cooling-feeds/', include(get_model_urls('dcim', 'coolingfeed', detail=False))), + path('cooling-feeds//', include(get_model_urls('dcim', 'coolingfeed'))), + path('mac-addresses/', include(get_model_urls('dcim', 'macaddress', detail=False))), path('mac-addresses//', include(get_model_urls('dcim', 'macaddress'))), diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index 43d3050a5..097774d7a 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -8,27 +8,66 @@ from django.utils.translation import gettext as _ from dcim.constants import MODULE_TOKEN -def get_module_bay_positions(module_bay): +def inherit_module_token(position, parent_positions): """ - Given a module bay, traverse up the module hierarchy and return - a list of bay position strings from root to leaf, resolving any - {module} tokens in each position using the parent position - (position inheritance). + Resolve a single {module} token in a bay position by inheriting from the position + one level deeper in a module bay hierarchy. Returns position unchanged unless + parent_positions is non-empty and position contains {module}, in which case the + token is substituted with parent_positions[-1]. + + Used by resolve_position_chain(), the single inheritance implementation shared by + get_module_bay_positions() and the module move planner. + """ + if parent_positions and MODULE_TOKEN in position: + return position.replace(MODULE_TOKEN, parent_positions[-1]) + return position + + +def get_module_bay_raw_positions(module_bay): + """ + Given a module bay, traverse up the module hierarchy and return the stored + (unresolved) bay position strings from root to leaf. + + Raises ValueError if the module bay hierarchy contains a cycle. """ positions = [] + visited = set() while module_bay: - pos = module_bay.position or '' - if positions and MODULE_TOKEN in pos: - pos = pos.replace(MODULE_TOKEN, positions[-1]) - positions.append(pos) - if module_bay.module: - module_bay = module_bay.module.module_bay - else: - module_bay = None + if module_bay.pk in visited: + raise ValueError(_("Module bay hierarchy contains a cycle.")) + visited.add(module_bay.pk) + positions.append(module_bay.position or '') + module_bay = module_bay.module.module_bay if module_bay.module else None positions.reverse() return positions +def resolve_position_chain(raw_positions): + """ + Apply leaf-to-root {module} token inheritance over a root-to-leaf list of raw bay + positions: each position inherits from the resolved position one level deeper, and + the leaf's own token is never resolved. Shared by get_module_bay_positions() and + the module move planner so a planned chain always equals what a fresh walk + computes once the planned positions are stored. + """ + resolved = [] + for position in reversed(raw_positions): + resolved.append(inherit_module_token(position, resolved)) + resolved.reverse() + return resolved + + +def get_module_bay_positions(module_bay): + """ + Given a module bay, traverse up the module hierarchy and return a list of bay + position strings from root to leaf, resolving any {module} tokens in each + position using the parent position (position inheritance). + + Raises ValueError if the module bay hierarchy contains a cycle. + """ + return resolve_position_chain(get_module_bay_raw_positions(module_bay)) + + def resolve_module_placeholder(value, positions): """ Resolve {module} placeholder tokens in a string using the given @@ -92,12 +131,29 @@ def create_cablepaths(objects): :param objects: Iterable of cabled objects (e.g. Interfaces) """ - from dcim.models import CablePath + from dcim.models import CablePath, Interface - # Arrange objects by cable connector. All objects with a null connector are grouped together. - origins = defaultdict(list) + # Expand any channelized interface into its channel subinterfaces. A channelized parent originates no path of its + # own; instead, each channel subinterface traces independently from the single connector position it occupies. + # Plain (non-channelized) origins pass through unchanged, keeping this expansion re-entrant so that + # rebuild_paths() -> create_cablepaths(cp.origins) does not re-expand the channel subinterfaces it already holds. + expanded = [] for obj in objects: - origins[obj.cable_connector].append(obj) + if isinstance(obj, Interface) and obj.channels: + expanded.extend(obj.child_interfaces.filter(channel_id__isnull=False, cable__isnull=False)) + else: + expanded.append(obj) + + # Arrange objects by cable connector. All objects with a null connector are grouped together. Channel + # subinterfaces must each originate their own path, as sharing a connector would otherwise collapse a group of + # siblings into a single malformed path. + origins = defaultdict(list) + for obj in expanded: + if isinstance(obj, Interface) and obj.channel_id: + if cp := CablePath.from_origin([obj]): + cp.save() + else: + origins[obj.cable_connector].append(obj) for connector, objects in origins.items(): if cp := CablePath.from_origin(objects): @@ -119,6 +175,54 @@ def rebuild_paths(terminations): create_cablepaths(cp.origins) +def rebuild_cable_paths(cable): + """ + Delete and rebuild every CablePath traversing the given Cable, tracing freshly from the Cable's current + terminations in both directions. Used when the channelization of a terminated interface changes (e.g. a channel + subinterface is added, moved, or removed) without the Cable itself being modified. + """ + from dcim.choices import CableEndChoices + from dcim.models import CablePath, CableTermination, PathEndpoint + + with transaction.atomic(using=router.db_for_write(CablePath)): + # Delete existing paths individually so each clears its `_path` back-reference on the originating endpoints. + for cp in CablePath.objects.filter(_nodes__contains=cable): + cp.delete() + + a_terminations, b_terminations = [], [] + for ct in CableTermination.objects.filter(cable=cable): + if ct.cable_end == CableEndChoices.SIDE_A: + a_terminations.append(ct.termination) + else: + b_terminations.append(ct.termination) + + for nodes in (a_terminations, b_terminations): + if not nodes: + continue + if isinstance(nodes[0], PathEndpoint): + create_cablepaths(nodes) + else: + rebuild_paths(nodes) + + +def update_interface_parents(device, interface_templates, module=None): + """ + Used for device and module instantiation. Iterates all InterfaceTemplates with a parent assigned and applies it to + the actual interfaces. Must run after all interfaces have been instantiated (so that every parent interface exists) + and before update_interface_bridges() (so that channel subinterfaces validate against a populated parent). + """ + Interface = apps.get_model('dcim', 'Interface') + + for interface_template in interface_templates.exclude(parent=None): + interface = Interface.objects.get(device=device, name=interface_template.resolve_name(module=module)) + interface.parent = Interface.objects.get( + device=device, + name=interface_template.parent.resolve_name(module=module) + ) + interface.full_clean() + interface.save() + + def update_interface_bridges(device, interface_templates, module=None): """ Used for device and module instantiation. Iterates all InterfaceTemplates with a bridge assigned diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 987493641..81a8069f1 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1,6 +1,7 @@ from django.conf import settings from django.contrib import messages from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.core.paginator import EmptyPage, PageNotAnInteger from django.db import router, transaction from django.db.models import Func, IntegerField, Prefetch @@ -10,6 +11,7 @@ from django.urls import reverse from django.utils.html import escape from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ +from django.utils.translation import ngettext from django.views.generic import View from circuits.models import Circuit, CircuitTermination @@ -20,6 +22,7 @@ from ipam.tables import VLANTranslationRuleTable from ipam.ui.panels import FHRPGroupAssignmentsPanel from netbox.object_actions import * from netbox.ui import actions, layout +from netbox.ui.breadcrumbs import Breadcrumb, filtered_list_url, object_view_url from netbox.ui.panels import ( CommentsPanel, ContextTablePanel, @@ -38,6 +41,7 @@ from utilities.query import count_related from utilities.query_functions import CollateAsChar from utilities.request import safe_for_redirect from utilities.views import ( + ConditionalLoginRequiredMixin, GetRelatedModelsMixin, GetReturnURLMixin, ObjectPermissionRequiredMixin, @@ -249,6 +253,12 @@ class RegionListView(generic.ObjectListView): class RegionView(GetRelatedModelsMixin, generic.ObjectView): queryset = Region.objects.all() layout = layout.SimpleLayout( + breadcrumbs=[ + Breadcrumb( + lambda o: o.get_ancestors(), + url=filtered_list_url('dcim:region_list', 'parent_id'), + ), + ], left_panels=[ NestedGroupObjectPanel(), TagsPanel(), @@ -382,6 +392,12 @@ class SiteGroupListView(generic.ObjectListView): class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView): queryset = SiteGroup.objects.all() layout = layout.SimpleLayout( + breadcrumbs=[ + Breadcrumb( + lambda o: o.get_ancestors(), + url=filtered_list_url('dcim:sitegroup_list', 'parent_id'), + ), + ], left_panels=[ NestedGroupObjectPanel(), TagsPanel(), @@ -666,8 +682,12 @@ class LocationListView(generic.ObjectListView): @register_model_view(Location) class LocationView(GetRelatedModelsMixin, generic.ObjectView): + template_name = 'generic/object.html' queryset = Location.objects.all() layout = layout.SimpleLayout( + breadcrumbs=[ + Breadcrumb(lambda o: o.get_ancestors()), + ], left_panels=[ panels.LocationPanel(), TagsPanel(), @@ -1104,6 +1124,14 @@ class RackElevationListView(generic.ObjectListView): class RackView(GetRelatedModelsMixin, generic.ObjectView): queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role') layout = layout.SimpleLayout( + breadcrumbs=[ + Breadcrumb('site', url=filtered_list_url('dcim:rack_list', 'site_id')), + Breadcrumb( + lambda o: o.location.get_ancestors() if o.location else [], + url=filtered_list_url('dcim:rack_list', 'location_id'), + ), + Breadcrumb('location', url=filtered_list_url('dcim:rack_list', 'location_id')), + ], left_panels=[ panels.RackPanel(), panels.RackDimensionsPanel(title=_('Dimensions')), @@ -1251,10 +1279,15 @@ class RackReservationListView(generic.ObjectListView): @register_model_view(RackReservation) class RackReservationView(generic.ObjectView): + template_name = 'generic/object.html' queryset = RackReservation.objects.annotate( unit_count=Func('units', function='CARDINALITY', output_field=IntegerField()) ) layout = layout.SimpleLayout( + breadcrumbs=[ + Breadcrumb('rack', url=filtered_list_url('dcim:rackreservation_list', 'rack_id')), + Breadcrumb(label=lambda o: f"{_('Units')} {o.unit_list}"), + ], left_panels=[ panels.RackPanel(accessor='object.rack', only=['region', 'site', 'location', 'group', 'name']), panels.RackReservationPanel(title=_('Reservation')), @@ -1415,11 +1448,19 @@ class DeviceTypeListView(generic.ObjectListView): filterset_form = forms.DeviceTypeFilterForm table = tables.DeviceTypeTable + def export_yaml(self): + # Avoid one module_bay_types query per module bay template across the export. + self.queryset = self.queryset.prefetch_related('modulebaytemplates__module_bay_types') + return super().export_yaml() + @register_model_view(DeviceType) class DeviceTypeView(GetRelatedModelsMixin, generic.ObjectView): queryset = DeviceType.objects.all() layout = layout.SimpleLayout( + breadcrumbs=[ + Breadcrumb('manufacturer', url=filtered_list_url('dcim:devicetype_list', 'manufacturer_id')), + ], left_panels=[ panels.DeviceTypePanel(), TagsPanel(), @@ -1435,9 +1476,9 @@ class DeviceTypeView(GetRelatedModelsMixin, generic.ObjectView): def get_extra_context(self, request, instance): return { 'related_models': self.get_related_models(request, instance, omit=[ - ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, - InventoryItemTemplate, InterfaceTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, - RearPortTemplate, + ConsolePortTemplate, ConsoleServerPortTemplate, CoolingIntakeTemplate, CoolingOutflowTemplate, + DeviceBayTemplate, FrontPortTemplate, InventoryItemTemplate, InterfaceTemplate, ModuleBayTemplate, + PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, ]), } @@ -1514,6 +1555,36 @@ class DeviceTypePowerOutletsView(DeviceTypeComponentsView): ) +@register_model_view(DeviceType, 'coolingintakes', path='cooling-intakes') +class DeviceTypeCoolingIntakesView(DeviceTypeComponentsView): + child_model = CoolingIntakeTemplate + table = tables.CoolingIntakeTemplateTable + filterset = filtersets.CoolingIntakeTemplateFilterSet + viewname = 'dcim:devicetype_coolingintakes' + tab = ViewTab( + label=_('Cooling Intakes'), + badge=lambda obj: obj.cooling_intake_template_count, + permission='dcim.view_coolingintaketemplate', + weight=590, + hide_if_empty=True + ) + + +@register_model_view(DeviceType, 'coolingoutflows', path='cooling-outflows') +class DeviceTypeCoolingOutflowsView(DeviceTypeComponentsView): + child_model = CoolingOutflowTemplate + table = tables.CoolingOutflowTemplateTable + filterset = filtersets.CoolingOutflowTemplateFilterSet + viewname = 'dcim:devicetype_coolingoutflows' + tab = ViewTab( + label=_('Cooling Outflows'), + badge=lambda obj: obj.cooling_outflow_template_count, + permission='dcim.view_coolingoutflowtemplate', + weight=600, + hide_if_empty=True + ) + + @register_model_view(DeviceType, 'interfaces') class DeviceTypeInterfacesView(DeviceTypeComponentsView): child_model = InterfaceTemplate @@ -1599,7 +1670,7 @@ class DeviceTypeInventoryItemsView(DeviceTypeComponentsView): label=_('Inventory Items'), badge=lambda obj: obj.inventory_item_template_count, permission='dcim.view_inventoryitemtemplate', - weight=590, + weight=610, hide_if_empty=True ) @@ -1612,6 +1683,8 @@ class DeviceTypeImportView(generic.BulkImportView): 'dcim.add_consoleserverporttemplate', 'dcim.add_powerporttemplate', 'dcim.add_poweroutlettemplate', + 'dcim.add_coolingintaketemplate', + 'dcim.add_coolingoutflowtemplate', 'dcim.add_interfacetemplate', 'dcim.add_frontporttemplate', 'dcim.add_rearporttemplate', @@ -1626,6 +1699,8 @@ class DeviceTypeImportView(generic.BulkImportView): 'console-server-ports': forms.ConsoleServerPortTemplateImportForm, 'power-ports': forms.PowerPortTemplateImportForm, 'power-outlets': forms.PowerOutletTemplateImportForm, + 'cooling-intakes': forms.CoolingIntakeTemplateImportForm, + 'cooling-outflows': forms.CoolingOutflowTemplateImportForm, 'interfaces': forms.InterfaceTemplateImportForm, 'rear-ports': forms.RearPortTemplateImportForm, 'front-ports': forms.FrontPortTemplateImportForm, @@ -1662,6 +1737,74 @@ class DeviceTypeBulkDeleteView(generic.BulkDeleteView): table = tables.DeviceTypeTable +# +# Module bay types +# + +@register_model_view(ModuleBayType, 'list', path='', detail=False) +class ModuleBayTypeListView(generic.ObjectListView): + queryset = ModuleBayType.objects.all() + filterset = filtersets.ModuleBayTypeFilterSet + filterset_form = forms.ModuleBayTypeFilterForm + table = tables.ModuleBayTypeTable + + +@register_model_view(ModuleBayType) +class ModuleBayTypeView(generic.ObjectView): + template_name = 'generic/object.html' + queryset = ModuleBayType.objects.all() + layout = layout.SimpleLayout( + left_panels=[ + panels.ModuleBayTypePanel(), + TagsPanel(), + CommentsPanel(), + ], + right_panels=[ + CustomFieldsPanel(), + ], + bottom_panels=[ + ObjectsTablePanel( + model='dcim.ModuleBay', + title=_('Module Bays'), + filters={'module_bay_type_id': lambda ctx: ctx['object'].pk}, + ), + ], + ) + + +@register_model_view(ModuleBayType, 'add', detail=False) +@register_model_view(ModuleBayType, 'edit') +class ModuleBayTypeEditView(generic.ObjectEditView): + queryset = ModuleBayType.objects.all() + form = forms.ModuleBayTypeForm + + +@register_model_view(ModuleBayType, 'delete') +class ModuleBayTypeDeleteView(generic.ObjectDeleteView): + queryset = ModuleBayType.objects.all() + + +@register_model_view(ModuleBayType, 'bulk_import', detail=False) +class ModuleBayTypeBulkImportView(generic.BulkImportView): + queryset = ModuleBayType.objects.all() + model_form = forms.ModuleBayTypeImportForm + + +@register_model_view(ModuleBayType, 'bulk_edit', path='edit', detail=False) +class ModuleBayTypeBulkEditView(generic.BulkEditView): + queryset = ModuleBayType.objects.all() + filterset = filtersets.ModuleBayTypeFilterSet + table = tables.ModuleBayTypeTable + form = forms.ModuleBayTypeBulkEditForm + + +@register_model_view(ModuleBayType, 'bulk_delete', path='delete', detail=False) +class ModuleBayTypeBulkDeleteView(generic.BulkDeleteView): + queryset = ModuleBayType.objects.all() + filterset = filtersets.ModuleBayTypeFilterSet + table = tables.ModuleBayTypeTable + + # # Module type profiles # @@ -1765,11 +1908,21 @@ class ModuleTypeListView(generic.ObjectListView): filterset_form = forms.ModuleTypeFilterForm table = tables.ModuleTypeTable + def export_yaml(self): + # Avoid one module_bay_types query per module type across the export. (Unlike + # DeviceType.to_yaml(), ModuleType.to_yaml() doesn't export module bay templates at + # all, so there's nothing to prefetch alongside it.) + self.queryset = self.queryset.prefetch_related('module_bay_types') + return super().export_yaml() + @register_model_view(ModuleType) class ModuleTypeView(GetRelatedModelsMixin, generic.ObjectView): queryset = ModuleType.objects.all() layout = layout.SimpleLayout( + breadcrumbs=[ + Breadcrumb('manufacturer', url=filtered_list_url('dcim:moduletype_list', 'manufacturer_id')), + ], left_panels=[ panels.ModuleTypePanel(), TagsPanel(), @@ -1789,9 +1942,9 @@ class ModuleTypeView(GetRelatedModelsMixin, generic.ObjectView): def get_extra_context(self, request, instance): return { 'related_models': self.get_related_models(request, instance, omit=[ - ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, - InventoryItemTemplate, InterfaceTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, - RearPortTemplate, + ConsolePortTemplate, ConsoleServerPortTemplate, CoolingIntakeTemplate, CoolingOutflowTemplate, + DeviceBayTemplate, FrontPortTemplate, InventoryItemTemplate, InterfaceTemplate, ModuleBayTemplate, + PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, ]), } @@ -1802,6 +1955,33 @@ class ModuleTypeEditView(generic.ObjectEditView): queryset = ModuleType.objects.all() form = forms.ModuleTypeForm + def post(self, request, *args, **kwargs): + response = super().post(request, *args, **kwargs) + # A successful save always redirects (302) or returns an HTMX redirect header. + # Quick-add creates new objects (no pk in kwargs), so it is excluded by the pk guard below. + saved = ( + getattr(response, 'status_code', None) == 302 or + (hasattr(response, 'headers') and 'HX-Location' in response.headers) + ) + if saved and kwargs.get('pk'): + try: + module_type = ModuleType.objects.prefetch_related('module_bay_types').get(pk=kwargs['pk']) + count = module_type.get_incompatible_modules().count() + if count: + messages.warning( + request, + ngettext( + '%(count)d installed module of this type is now incompatible with its module bay ' + 'due to conflicting bay type constraints.', + '%(count)d installed modules of this type are now incompatible with their module bays ' + 'due to conflicting bay type constraints.', + count, + ) % {'count': count} + ) + except ModuleType.DoesNotExist: + pass + return response + @register_model_view(ModuleType, 'delete') class ModuleTypeDeleteView(generic.ObjectDeleteView): @@ -1868,6 +2048,36 @@ class ModuleTypePowerOutletsView(ModuleTypeComponentsView): ) +@register_model_view(ModuleType, 'coolingintakes', path='cooling-intakes') +class ModuleTypeCoolingIntakesView(ModuleTypeComponentsView): + child_model = CoolingIntakeTemplate + table = tables.CoolingIntakeTemplateTable + filterset = filtersets.CoolingIntakeTemplateFilterSet + viewname = 'dcim:moduletype_coolingintakes' + tab = ViewTab( + label=_('Cooling Intakes'), + badge=lambda obj: obj.cooling_intake_template_count, + permission='dcim.view_coolingintaketemplate', + weight=570, + hide_if_empty=True + ) + + +@register_model_view(ModuleType, 'coolingoutflows', path='cooling-outflows') +class ModuleTypeCoolingOutflowsView(ModuleTypeComponentsView): + child_model = CoolingOutflowTemplate + table = tables.CoolingOutflowTemplateTable + filterset = filtersets.CoolingOutflowTemplateFilterSet + viewname = 'dcim:moduletype_coolingoutflows' + tab = ViewTab( + label=_('Cooling Outflows'), + badge=lambda obj: obj.cooling_outflow_template_count, + permission='dcim.view_coolingoutflowtemplate', + weight=580, + hide_if_empty=True + ) + + @register_model_view(ModuleType, 'interfaces') class ModuleTypeInterfacesView(ModuleTypeComponentsView): child_model = InterfaceTemplate @@ -1923,7 +2133,7 @@ class ModuleTypeModuleBaysView(ModuleTypeComponentsView): label=_('Module Bays'), badge=lambda obj: obj.module_bay_template_count, permission='dcim.view_modulebaytemplate', - weight=570, + weight=590, hide_if_empty=True ) @@ -1936,6 +2146,8 @@ class ModuleTypeImportView(generic.BulkImportView): 'dcim.add_consoleserverporttemplate', 'dcim.add_powerporttemplate', 'dcim.add_poweroutlettemplate', + 'dcim.add_coolingintaketemplate', + 'dcim.add_coolingoutflowtemplate', 'dcim.add_interfacetemplate', 'dcim.add_frontporttemplate', 'dcim.add_rearporttemplate', @@ -1948,6 +2160,8 @@ class ModuleTypeImportView(generic.BulkImportView): 'console-server-ports': forms.ConsoleServerPortTemplateImportForm, 'power-ports': forms.PowerPortTemplateImportForm, 'power-outlets': forms.PowerOutletTemplateImportForm, + 'cooling-intakes': forms.CoolingIntakeTemplateImportForm, + 'cooling-outflows': forms.CoolingOutflowTemplateImportForm, 'interfaces': forms.InterfaceTemplateImportForm, 'rear-ports': forms.RearPortTemplateImportForm, 'front-ports': forms.FrontPortTemplateImportForm, @@ -1969,6 +2183,35 @@ class ModuleTypeBulkEditView(generic.BulkEditView): table = tables.ModuleTypeTable form = forms.ModuleTypeBulkEditForm + def post_save_operations(self, form, obj): + super().post_save_operations(form, obj) + add = form.cleaned_data.get('add_module_bay_types') + remove = form.cleaned_data.get('remove_module_bay_types') + if add: + obj.module_bay_types.add(*add) + if remove: + obj.module_bay_types.remove(*remove) + if add or remove: + # Counts current incompatibilities, not just newly-introduced ones; may over-warn + # if pre-existing incompatibilities exist, but safe to under-warn on. + self._incompatible_count += obj.get_incompatible_modules().count() + + def post(self, request, **kwargs): + self._incompatible_count = 0 + response = super().post(request, **kwargs) + if self._incompatible_count and getattr(response, 'status_code', None) == 302: + messages.warning( + request, + ngettext( + '%(count)d installed module is now incompatible with its module bay ' + 'due to conflicting bay type constraints.', + '%(count)d installed modules are now incompatible with their module bays ' + 'due to conflicting bay type constraints.', + self._incompatible_count, + ) % {'count': self._incompatible_count} + ) + return response + @register_model_view(ModuleType, 'bulk_rename', path='rename', detail=False) class ModuleTypeBulkRenameView(generic.BulkRenameView): @@ -2150,6 +2393,88 @@ class PowerOutletTemplateBulkDeleteView(generic.BulkDeleteView): table = tables.PowerOutletTemplateTable +# +# Cooling port templates +# + +@register_model_view(CoolingIntakeTemplate, 'add', detail=False) +class CoolingIntakeTemplateCreateView(generic.ComponentCreateView): + queryset = CoolingIntakeTemplate.objects.all() + form = forms.CoolingIntakeTemplateCreateForm + model_form = forms.CoolingIntakeTemplateForm + + +@register_model_view(CoolingIntakeTemplate, 'edit') +class CoolingIntakeTemplateEditView(generic.ObjectEditView): + queryset = CoolingIntakeTemplate.objects.all() + form = forms.CoolingIntakeTemplateForm + + +@register_model_view(CoolingIntakeTemplate, 'delete') +class CoolingIntakeTemplateDeleteView(generic.ObjectDeleteView): + queryset = CoolingIntakeTemplate.objects.all() + + +@register_model_view(CoolingIntakeTemplate, 'bulk_edit', path='edit', detail=False) +class CoolingIntakeTemplateBulkEditView(generic.BulkEditView): + queryset = CoolingIntakeTemplate.objects.all() + table = tables.CoolingIntakeTemplateTable + form = forms.CoolingIntakeTemplateBulkEditForm + + +@register_model_view(CoolingIntakeTemplate, 'bulk_rename', path='rename', detail=False) +class CoolingIntakeTemplateBulkRenameView(generic.BulkRenameView): + queryset = CoolingIntakeTemplate.objects.all() + rename_fields = ('name', 'label') + + +@register_model_view(CoolingIntakeTemplate, 'bulk_delete', path='delete', detail=False) +class CoolingIntakeTemplateBulkDeleteView(generic.BulkDeleteView): + queryset = CoolingIntakeTemplate.objects.all() + table = tables.CoolingIntakeTemplateTable + + +# +# Cooling outlet templates +# + +@register_model_view(CoolingOutflowTemplate, 'add', detail=False) +class CoolingOutflowTemplateCreateView(generic.ComponentCreateView): + queryset = CoolingOutflowTemplate.objects.all() + form = forms.CoolingOutflowTemplateCreateForm + model_form = forms.CoolingOutflowTemplateForm + + +@register_model_view(CoolingOutflowTemplate, 'edit') +class CoolingOutflowTemplateEditView(generic.ObjectEditView): + queryset = CoolingOutflowTemplate.objects.all() + form = forms.CoolingOutflowTemplateForm + + +@register_model_view(CoolingOutflowTemplate, 'delete') +class CoolingOutflowTemplateDeleteView(generic.ObjectDeleteView): + queryset = CoolingOutflowTemplate.objects.all() + + +@register_model_view(CoolingOutflowTemplate, 'bulk_edit', path='edit', detail=False) +class CoolingOutflowTemplateBulkEditView(generic.BulkEditView): + queryset = CoolingOutflowTemplate.objects.all() + table = tables.CoolingOutflowTemplateTable + form = forms.CoolingOutflowTemplateBulkEditForm + + +@register_model_view(CoolingOutflowTemplate, 'bulk_rename', path='rename', detail=False) +class CoolingOutflowTemplateBulkRenameView(generic.BulkRenameView): + queryset = CoolingOutflowTemplate.objects.all() + rename_fields = ('name', 'label') + + +@register_model_view(CoolingOutflowTemplate, 'bulk_delete', path='delete', detail=False) +class CoolingOutflowTemplateBulkDeleteView(generic.BulkDeleteView): + queryset = CoolingOutflowTemplate.objects.all() + table = tables.CoolingOutflowTemplateTable + + # # Interface templates # @@ -2301,6 +2626,17 @@ class ModuleBayTemplateBulkEditView(generic.BulkEditView): table = tables.ModuleBayTemplateTable form = forms.ModuleBayTemplateBulkEditForm + def post_save_operations(self, form, obj): + # Unlike the ModuleType and ModuleBay editors, no compatibility warning: a template + # has no installed module to invalidate. + super().post_save_operations(form, obj) + add = form.cleaned_data.get('add_module_bay_types') + remove = form.cleaned_data.get('remove_module_bay_types') + if add: + obj.module_bay_types.add(*add) + if remove: + obj.module_bay_types.remove(*remove) + @register_model_view(ModuleBayTemplate, 'bulk_rename', path='rename', detail=False) class ModuleBayTemplateBulkRenameView(generic.BulkRenameView): @@ -2435,6 +2771,12 @@ class DeviceRoleListView(generic.ObjectListView): class DeviceRoleView(GetRelatedModelsMixin, generic.ObjectView): queryset = DeviceRole.objects.all() layout = layout.SimpleLayout( + breadcrumbs=[ + Breadcrumb( + lambda o: o.get_ancestors(), + url=filtered_list_url('dcim:devicerole_list', 'parent_id'), + ), + ], left_panels=[ panels.DeviceRolePanel(), TagsPanel(), @@ -2536,6 +2878,13 @@ class PlatformListView(generic.ObjectListView): class PlatformView(GetRelatedModelsMixin, generic.ObjectView): queryset = Platform.objects.all() layout = layout.SimpleLayout( + breadcrumbs=[ + Breadcrumb('manufacturer', url=filtered_list_url('dcim:platform_list', 'manufacturer_id')), + Breadcrumb( + lambda o: o.get_ancestors(), + url=filtered_list_url('dcim:platform_list', 'parent_id'), + ), + ], left_panels=[ panels.PlatformPanel(), TagsPanel(), @@ -2647,8 +2996,8 @@ class DeviceView(generic.ObjectView): actions.AddObject( 'ipam.Service', url_params={ - 'parent_object_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk, - 'parent': lambda ctx: ctx['object'].pk + 'parent_content_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk, + 'parent_object_id': lambda ctx: ctx['object'].pk } ), ], @@ -2753,6 +3102,38 @@ class DevicePowerOutletsView(DeviceComponentsView): ) +@register_model_view(Device, 'coolingintakes', path='cooling-intakes') +class DeviceCoolingIntakesView(DeviceComponentsView): + child_model = CoolingIntake + table = tables.DeviceCoolingIntakeTable + filterset = filtersets.CoolingIntakeFilterSet + filterset_form = forms.CoolingIntakeFilterForm + actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete) + tab = ViewTab( + label=_('Cooling Intakes'), + badge=lambda obj: obj.cooling_intake_count, + permission='dcim.view_coolingintake', + weight=590, + hide_if_empty=True + ) + + +@register_model_view(Device, 'coolingoutflows', path='cooling-outflows') +class DeviceCoolingOutflowsView(DeviceComponentsView): + child_model = CoolingOutflow + table = tables.DeviceCoolingOutflowTable + filterset = filtersets.CoolingOutflowFilterSet + filterset_form = forms.CoolingOutflowFilterForm + actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete) + tab = ViewTab( + label=_('Cooling Outflows'), + badge=lambda obj: obj.cooling_outflow_count, + permission='dcim.view_coolingoutflow', + weight=600, + hide_if_empty=True + ) + + @register_model_view(Device, 'interfaces') class DeviceInterfacesView(DeviceComponentsView): child_model = Interface @@ -2851,14 +3232,22 @@ class DeviceInventoryView(DeviceComponentsView): label=_('Inventory Items'), badge=lambda obj: obj.inventory_item_count, permission='dcim.view_inventoryitem', - weight=590, + weight=610, hide_if_empty=True ) + def get_children(self, request, parent): + # DeviceInventoryItemTable indents rows by record.level; under MPTT, + # TreeManager forced tree-flatten (tree_id, lft) ordering so descendants + # were contiguous with their parent. InventoryItem.Meta.ordering is a + # flat sort suited to the global list; for the indented device tab, + # order by ltree path so the rendered hierarchy is correct. + return super().get_children(request, parent).order_by('path') + @register_model_view(Device, 'configcontext', path='config-context') class DeviceConfigContextView(ObjectConfigContextView): - queryset = Device.objects.annotate_config_context_data() + queryset = Device.objects.all() base_template = 'dcim/device/base.html' tab = ViewTab( label=_('Config Context'), @@ -2952,9 +3341,16 @@ class ModuleListView(generic.ObjectListView): @register_model_view(Module) class ModuleView(GetRelatedModelsMixin, generic.ObjectView): - queryset = Module.objects.all() + queryset = Module.objects.prefetch_related( + 'module_bay__module_bay_types', + 'module_type__module_bay_types', + ) layout = layout.SimpleLayout( + breadcrumbs=[ + Breadcrumb('module_type', url=filtered_list_url('dcim:module_list', 'module_type_id')), + ], left_panels=[ + panels.BayTypeIncompatibilityPanel(), panels.ModulePanel(), TagsPanel(), CommentsPanel(), @@ -3022,8 +3418,12 @@ class ConsolePortListView(generic.ObjectListView): @register_model_view(ConsolePort) class ConsolePortView(generic.ObjectView): + template_name = 'generic/object.html' queryset = ConsolePort.objects.all() layout = layout.SimpleLayout( + breadcrumbs=[ + Breadcrumb('device', url=object_view_url('dcim:device_consoleports')), + ], left_panels=[ panels.ConsolePortPanel(), CustomFieldsPanel(), @@ -3116,8 +3516,12 @@ class ConsoleServerPortListView(generic.ObjectListView): @register_model_view(ConsoleServerPort) class ConsoleServerPortView(generic.ObjectView): + template_name = 'generic/object.html' queryset = ConsoleServerPort.objects.all() layout = layout.SimpleLayout( + breadcrumbs=[ + Breadcrumb('device', url=object_view_url('dcim:device_consoleserverports')), + ], left_panels=[ panels.ConsoleServerPortPanel(), CustomFieldsPanel(), @@ -3206,8 +3610,12 @@ class PowerPortListView(generic.ObjectListView): @register_model_view(PowerPort) class PowerPortView(generic.ObjectView): + template_name = 'generic/object.html' queryset = PowerPort.objects.all() layout = layout.SimpleLayout( + breadcrumbs=[ + Breadcrumb('device', url=object_view_url('dcim:device_powerports')), + ], left_panels=[ panels.PowerPortPanel(), CustomFieldsPanel(), @@ -3295,8 +3703,12 @@ class PowerOutletListView(generic.ObjectListView): @register_model_view(PowerOutlet) class PowerOutletView(generic.ObjectView): + template_name = 'generic/object.html' queryset = PowerOutlet.objects.all() layout = layout.SimpleLayout( + breadcrumbs=[ + Breadcrumb('device', url=object_view_url('dcim:device_poweroutlets')), + ], left_panels=[ panels.PowerOutletPanel(), CustomFieldsPanel(), @@ -3369,6 +3781,160 @@ class PowerOutletBulkDeleteView(generic.BulkDeleteView): register_model_view(PowerOutlet, 'trace', kwargs={'model': PowerOutlet})(PathTraceView) +# +# Cooling ports +# + +@register_model_view(CoolingIntake, 'list', path='', detail=False) +class CoolingIntakeListView(generic.ObjectListView): + queryset = CoolingIntake.objects.all() + filterset = filtersets.CoolingIntakeFilterSet + filterset_form = forms.CoolingIntakeFilterForm + table = tables.CoolingIntakeTable + + +@register_model_view(CoolingIntake) +class CoolingIntakeView(generic.ObjectView): + queryset = CoolingIntake.objects.all() + template_name = 'generic/object.html' + layout = layout.SimpleLayout( + breadcrumbs=[ + Breadcrumb('device', url=object_view_url('dcim:device_coolingintakes')), + ], + left_panels=[ + panels.CoolingIntakePanel(), + CustomFieldsPanel(), + TagsPanel(), + ], + right_panels=[ + panels.InventoryItemsPanel(), + ], + ) + + +@register_model_view(CoolingIntake, 'add', detail=False) +class CoolingIntakeCreateView(generic.ComponentCreateView): + queryset = CoolingIntake.objects.all() + form = forms.CoolingIntakeCreateForm + model_form = forms.CoolingIntakeForm + + +@register_model_view(CoolingIntake, 'edit') +class CoolingIntakeEditView(generic.ObjectEditView): + queryset = CoolingIntake.objects.all() + form = forms.CoolingIntakeForm + + +@register_model_view(CoolingIntake, 'delete') +class CoolingIntakeDeleteView(generic.ObjectDeleteView): + queryset = CoolingIntake.objects.all() + + +@register_model_view(CoolingIntake, 'bulk_import', path='import', detail=False) +class CoolingIntakeBulkImportView(generic.BulkImportView): + queryset = CoolingIntake.objects.all() + model_form = forms.CoolingIntakeImportForm + + +@register_model_view(CoolingIntake, 'bulk_edit', path='edit', detail=False) +class CoolingIntakeBulkEditView(generic.BulkEditView): + queryset = CoolingIntake.objects.all() + filterset = filtersets.CoolingIntakeFilterSet + table = tables.CoolingIntakeTable + form = forms.CoolingIntakeBulkEditForm + + +@register_model_view(CoolingIntake, 'bulk_rename', path='rename', detail=False) +class CoolingIntakeBulkRenameView(generic.BulkRenameView): + queryset = CoolingIntake.objects.all() + filterset = filtersets.CoolingIntakeFilterSet + rename_fields = ('name', 'label') + + +@register_model_view(CoolingIntake, 'bulk_delete', path='delete', detail=False) +class CoolingIntakeBulkDeleteView(generic.BulkDeleteView): + queryset = CoolingIntake.objects.all() + filterset = filtersets.CoolingIntakeFilterSet + table = tables.CoolingIntakeTable + + +# +# Cooling outlets +# + +@register_model_view(CoolingOutflow, 'list', path='', detail=False) +class CoolingOutflowListView(generic.ObjectListView): + queryset = CoolingOutflow.objects.all() + filterset = filtersets.CoolingOutflowFilterSet + filterset_form = forms.CoolingOutflowFilterForm + table = tables.CoolingOutflowTable + + +@register_model_view(CoolingOutflow) +class CoolingOutflowView(generic.ObjectView): + queryset = CoolingOutflow.objects.all() + template_name = 'generic/object.html' + layout = layout.SimpleLayout( + breadcrumbs=[ + Breadcrumb('device', url=object_view_url('dcim:device_coolingoutflows')), + ], + left_panels=[ + panels.CoolingOutflowPanel(), + CustomFieldsPanel(), + TagsPanel(), + ], + right_panels=[ + panels.InventoryItemsPanel(), + ], + ) + + +@register_model_view(CoolingOutflow, 'add', detail=False) +class CoolingOutflowCreateView(generic.ComponentCreateView): + queryset = CoolingOutflow.objects.all() + form = forms.CoolingOutflowCreateForm + model_form = forms.CoolingOutflowForm + + +@register_model_view(CoolingOutflow, 'edit') +class CoolingOutflowEditView(generic.ObjectEditView): + queryset = CoolingOutflow.objects.all() + form = forms.CoolingOutflowForm + + +@register_model_view(CoolingOutflow, 'delete') +class CoolingOutflowDeleteView(generic.ObjectDeleteView): + queryset = CoolingOutflow.objects.all() + + +@register_model_view(CoolingOutflow, 'bulk_import', path='import', detail=False) +class CoolingOutflowBulkImportView(generic.BulkImportView): + queryset = CoolingOutflow.objects.all() + model_form = forms.CoolingOutflowImportForm + + +@register_model_view(CoolingOutflow, 'bulk_edit', path='edit', detail=False) +class CoolingOutflowBulkEditView(generic.BulkEditView): + queryset = CoolingOutflow.objects.all() + filterset = filtersets.CoolingOutflowFilterSet + table = tables.CoolingOutflowTable + form = forms.CoolingOutflowBulkEditForm + + +@register_model_view(CoolingOutflow, 'bulk_rename', path='rename', detail=False) +class CoolingOutflowBulkRenameView(generic.BulkRenameView): + queryset = CoolingOutflow.objects.all() + filterset = filtersets.CoolingOutflowFilterSet + rename_fields = ('name', 'label') + + +@register_model_view(CoolingOutflow, 'bulk_delete', path='delete', detail=False) +class CoolingOutflowBulkDeleteView(generic.BulkDeleteView): + queryset = CoolingOutflow.objects.all() + filterset = filtersets.CoolingOutflowFilterSet + table = tables.CoolingOutflowTable + + # # Interfaces # @@ -3385,6 +3951,9 @@ class InterfaceListView(generic.ObjectListView): class InterfaceView(generic.ObjectView): queryset = Interface.objects.all() layout = layout.SimpleLayout( + breadcrumbs=[ + Breadcrumb('device', url=object_view_url('dcim:device_interfaces')), + ], left_panels=[ panels.InterfacePanel(), panels.RelatedInterfacesPanel(), @@ -3413,6 +3982,11 @@ class InterfaceView(generic.ObjectView): filters={'interface_id': lambda ctx: ctx['object'].pk}, title=_('MAC Addresses'), exclude_columns=['assigned_object', 'assigned_object_parent'], + actions=[ + actions.AddObject( + 'dcim.MACAddress', url_params={'interface': lambda ctx: ctx['object'].pk} + ), + ], ), ObjectsTablePanel( model='ipam.VLAN', @@ -3561,8 +4135,12 @@ class FrontPortListView(generic.ObjectListView): @register_model_view(FrontPort) class FrontPortView(generic.ObjectView): + template_name = 'generic/object.html' queryset = FrontPort.objects.all() layout = layout.SimpleLayout( + breadcrumbs=[ + Breadcrumb('device', url=object_view_url('dcim:device_frontports')), + ], left_panels=[ panels.FrontPortPanel(), CustomFieldsPanel(), @@ -3665,8 +4243,12 @@ class RearPortListView(generic.ObjectListView): @register_model_view(RearPort) class RearPortView(generic.ObjectView): + template_name = 'generic/object.html' queryset = RearPort.objects.all() layout = layout.SimpleLayout( + breadcrumbs=[ + Breadcrumb('device', url=object_view_url('dcim:device_rearports')), + ], left_panels=[ panels.RearPortPanel(), CustomFieldsPanel(), @@ -3767,9 +4349,17 @@ class ModuleBayListView(generic.ObjectListView): @register_model_view(ModuleBay) class ModuleBayView(generic.ObjectView): - queryset = ModuleBay.objects.all() + template_name = 'generic/object.html' + queryset = ModuleBay.objects.prefetch_related( + 'module_bay_types', + 'installed_module__module_type__module_bay_types', + ) layout = layout.SimpleLayout( + breadcrumbs=[ + Breadcrumb('device', url=object_view_url('dcim:device_modulebays')), + ], left_panels=[ + panels.BayTypeIncompatibilityPanel(), panels.ModuleBayPanel(), TagsPanel(), ], @@ -3792,6 +4382,29 @@ class ModuleBayEditView(generic.ObjectEditView): queryset = ModuleBay.objects.all() form = forms.ModuleBayForm + def post(self, request, *args, **kwargs): + response = super().post(request, *args, **kwargs) + # A successful save always redirects (302) or returns an HTMX redirect header. + # Quick-add creates new objects (no pk in kwargs), so it is excluded by the pk guard below. + saved = ( + getattr(response, 'status_code', None) == 302 or + (hasattr(response, 'headers') and 'HX-Location' in response.headers) + ) + if saved and kwargs.get('pk'): + try: + bay = ModuleBay.objects.prefetch_related( + 'module_bay_types', 'installed_module__module_type__module_bay_types' + ).get(pk=kwargs['pk']) + if not bay.is_module_compatible: + messages.warning( + request, + _('The module currently installed in this bay is incompatible with the new bay type ' + 'constraints. Consider removing or replacing it.') + ) + except ModuleBay.DoesNotExist: + pass + return response + @register_model_view(ModuleBay, 'delete') class ModuleBayDeleteView(generic.ObjectDeleteView): @@ -3811,6 +4424,35 @@ class ModuleBayBulkEditView(generic.BulkEditView): table = tables.ModuleBayTable form = forms.ModuleBayBulkEditForm + def post_save_operations(self, form, obj): + super().post_save_operations(form, obj) + add = form.cleaned_data.get('add_module_bay_types') + remove = form.cleaned_data.get('remove_module_bay_types') + if add: + obj.module_bay_types.add(*add) + if remove: + obj.module_bay_types.remove(*remove) + if add or remove: + # Counts current incompatibilities, not just newly-introduced ones; may over-warn + # if pre-existing incompatibilities exist, but safe to under-warn on. + self._incompatible_count += int(not obj.is_module_compatible) + + def post(self, request, **kwargs): + self._incompatible_count = 0 + response = super().post(request, **kwargs) + if self._incompatible_count and getattr(response, 'status_code', None) == 302: + messages.warning( + request, + ngettext( + '%(count)d module bay now has an incompatible module installed ' + 'due to conflicting bay type constraints.', + '%(count)d module bays now have incompatible modules installed ' + 'due to conflicting bay type constraints.', + self._incompatible_count, + ) % {'count': self._incompatible_count} + ) + return response + @register_model_view(ModuleBay, 'bulk_rename', path='rename', detail=False) class ModuleBayBulkRenameView(generic.BulkRenameView): @@ -3840,8 +4482,12 @@ class DeviceBayListView(generic.ObjectListView): @register_model_view(DeviceBay) class DeviceBayView(generic.ObjectView): + template_name = 'generic/object.html' queryset = DeviceBay.objects.all() layout = layout.SimpleLayout( + breadcrumbs=[ + Breadcrumb('device', url=object_view_url('dcim:device_devicebays')), + ], left_panels=[ panels.DeviceBayPanel(), CustomFieldsPanel(), @@ -3994,8 +4640,12 @@ class InventoryItemListView(generic.ObjectListView): @register_model_view(InventoryItem) class InventoryItemView(generic.ObjectView): + template_name = 'generic/object.html' queryset = InventoryItem.objects.all() layout = layout.SimpleLayout( + breadcrumbs=[ + Breadcrumb('device', url=object_view_url('dcim:device_inventory')), + ], left_panels=[ panels.InventoryItemPanel(), CustomFieldsPanel(), @@ -4196,6 +4846,28 @@ class DeviceBulkAddPowerOutletView(generic.BulkComponentCreateView): default_return_url = 'dcim:device_list' +class DeviceBulkAddCoolingIntakeView(generic.BulkComponentCreateView): + parent_model = Device + parent_field = 'device' + form = forms.CoolingIntakeBulkCreateForm + queryset = CoolingIntake.objects.all() + model_form = forms.CoolingIntakeForm + filterset = filtersets.DeviceFilterSet + table = tables.DeviceTable + default_return_url = 'dcim:device_list' + + +class DeviceBulkAddCoolingOutflowView(generic.BulkComponentCreateView): + parent_model = Device + parent_field = 'device' + form = forms.CoolingOutflowBulkCreateForm + queryset = CoolingOutflow.objects.all() + model_form = forms.CoolingOutflowForm + filterset = filtersets.DeviceFilterSet + table = tables.DeviceTable + default_return_url = 'dcim:device_list' + + class DeviceBulkAddInterfaceView(generic.BulkComponentCreateView): parent_model = Device parent_field = 'device' @@ -4742,8 +5414,17 @@ class PowerPanelListView(generic.ObjectListView): @register_model_view(PowerPanel) class PowerPanelView(GetRelatedModelsMixin, generic.ObjectView): + template_name = 'generic/object.html' queryset = PowerPanel.objects.all() layout = layout.SimpleLayout( + breadcrumbs=[ + Breadcrumb('site', url=filtered_list_url('dcim:powerpanel_list', 'site_id')), + Breadcrumb( + lambda o: o.location.get_ancestors() if o.location else [], + url=filtered_list_url('dcim:powerpanel_list', 'location_id'), + ), + Breadcrumb('location', url=filtered_list_url('dcim:powerpanel_list', 'location_id')), + ], left_panels=[ panels.PowerPanelPanel(), TagsPanel(), @@ -4826,8 +5507,14 @@ class PowerFeedListView(generic.ObjectListView): @register_model_view(PowerFeed) class PowerFeedView(generic.ObjectView): + template_name = 'generic/object.html' queryset = PowerFeed.objects.all() layout = layout.SimpleLayout( + breadcrumbs=[ + Breadcrumb('power_panel.site', url=filtered_list_url('dcim:powerfeed_list', 'site_id')), + Breadcrumb('power_panel', url=filtered_list_url('dcim:powerfeed_list', 'power_panel_id')), + Breadcrumb('rack', url=filtered_list_url('dcim:powerfeed_list', 'rack_id')), + ], left_panels=[ panels.PowerFeedPanel(), panels.PowerFeedElectricalPanel(), @@ -4894,6 +5581,174 @@ class PowerFeedBulkDeleteView(generic.BulkDeleteView): register_model_view(PowerFeed, 'trace', kwargs={'model': PowerFeed})(PathTraceView) +# +# Cooling sources +# + +@register_model_view(CoolingSource, 'list', path='', detail=False) +class CoolingSourceListView(generic.ObjectListView): + queryset = CoolingSource.objects.annotate( + coolingfeed_count=count_related(CoolingFeed, 'cooling_source') + ) + filterset = filtersets.CoolingSourceFilterSet + filterset_form = forms.CoolingSourceFilterForm + table = tables.CoolingSourceTable + + +@register_model_view(CoolingSource) +class CoolingSourceView(GetRelatedModelsMixin, generic.ObjectView): + queryset = CoolingSource.objects.all() + template_name = 'generic/object.html' + layout = layout.SimpleLayout( + breadcrumbs=[ + Breadcrumb('site', url=filtered_list_url('dcim:coolingsource_list', 'site_id')), + Breadcrumb( + lambda o: o.location.get_ancestors() if o.location else [], + url=filtered_list_url('dcim:coolingsource_list', 'location_id'), + ), + Breadcrumb('location', url=filtered_list_url('dcim:coolingsource_list', 'location_id')), + ], + left_panels=[ + panels.CoolingSourcePanel(), + TagsPanel(), + CommentsPanel(), + ], + right_panels=[ + RelatedObjectsPanel(), + CustomFieldsPanel(), + ImageAttachmentsPanel(), + ], + bottom_panels=[ + ObjectsTablePanel( + model='dcim.CoolingFeed', + filters={'cooling_source_id': lambda ctx: ctx['object'].pk}, + actions=[ + actions.AddObject('dcim.CoolingFeed', url_params={'cooling_source': lambda ctx: ctx['object'].pk}), + ], + ), + ], + ) + + def get_extra_context(self, request, instance): + return { + 'related_models': self.get_related_models(request, instance), + } + + +@register_model_view(CoolingSource, 'add', detail=False) +@register_model_view(CoolingSource, 'edit') +class CoolingSourceEditView(generic.ObjectEditView): + queryset = CoolingSource.objects.all() + form = forms.CoolingSourceForm + + +@register_model_view(CoolingSource, 'delete') +class CoolingSourceDeleteView(generic.ObjectDeleteView): + queryset = CoolingSource.objects.all() + + +@register_model_view(CoolingSource, 'bulk_import', path='import', detail=False) +class CoolingSourceBulkImportView(generic.BulkImportView): + queryset = CoolingSource.objects.all() + model_form = forms.CoolingSourceImportForm + + +@register_model_view(CoolingSource, 'bulk_edit', path='edit', detail=False) +class CoolingSourceBulkEditView(generic.BulkEditView): + queryset = CoolingSource.objects.all() + filterset = filtersets.CoolingSourceFilterSet + table = tables.CoolingSourceTable + form = forms.CoolingSourceBulkEditForm + + +@register_model_view(CoolingSource, 'bulk_rename', path='rename', detail=False) +class CoolingSourceBulkRenameView(generic.BulkRenameView): + queryset = CoolingSource.objects.all() + filterset = filtersets.CoolingSourceFilterSet + + +@register_model_view(CoolingSource, 'bulk_delete', path='delete', detail=False) +class CoolingSourceBulkDeleteView(generic.BulkDeleteView): + queryset = CoolingSource.objects.annotate( + coolingfeed_count=count_related(CoolingFeed, 'cooling_source') + ) + filterset = filtersets.CoolingSourceFilterSet + table = tables.CoolingSourceTable + + +# +# Cooling feeds +# + +@register_model_view(CoolingFeed, 'list', path='', detail=False) +class CoolingFeedListView(generic.ObjectListView): + queryset = CoolingFeed.objects.all() + filterset = filtersets.CoolingFeedFilterSet + filterset_form = forms.CoolingFeedFilterForm + table = tables.CoolingFeedTable + + +@register_model_view(CoolingFeed) +class CoolingFeedView(generic.ObjectView): + queryset = CoolingFeed.objects.all() + template_name = 'generic/object.html' + layout = layout.SimpleLayout( + breadcrumbs=[ + Breadcrumb('cooling_source.site', url=filtered_list_url('dcim:coolingfeed_list', 'site_id')), + Breadcrumb('cooling_source', url=filtered_list_url('dcim:coolingfeed_list', 'cooling_source_id')), + Breadcrumb('rack', url=filtered_list_url('dcim:coolingfeed_list', 'rack_id')), + ], + left_panels=[ + panels.CoolingFeedPanel(), + panels.CoolingFeedCharacteristicsPanel(), + CustomFieldsPanel(), + TagsPanel(), + ], + right_panels=[ + CommentsPanel(), + ], + ) + + +@register_model_view(CoolingFeed, 'add', detail=False) +@register_model_view(CoolingFeed, 'edit') +class CoolingFeedEditView(generic.ObjectEditView): + queryset = CoolingFeed.objects.all() + form = forms.CoolingFeedForm + + +@register_model_view(CoolingFeed, 'delete') +class CoolingFeedDeleteView(generic.ObjectDeleteView): + queryset = CoolingFeed.objects.all() + + +@register_model_view(CoolingFeed, 'bulk_import', path='import', detail=False) +class CoolingFeedBulkImportView(generic.BulkImportView): + queryset = CoolingFeed.objects.all() + model_form = forms.CoolingFeedImportForm + + +@register_model_view(CoolingFeed, 'bulk_edit', path='edit', detail=False) +class CoolingFeedBulkEditView(generic.BulkEditView): + queryset = CoolingFeed.objects.all() + filterset = filtersets.CoolingFeedFilterSet + table = tables.CoolingFeedTable + form = forms.CoolingFeedBulkEditForm + + +@register_model_view(CoolingFeed, 'bulk_rename', path='rename', detail=False) +class CoolingFeedBulkRenameView(generic.BulkRenameView): + queryset = CoolingFeed.objects.all() + filterset = filtersets.CoolingFeedFilterSet + + +@register_model_view(CoolingFeed, 'bulk_delete', path='delete', detail=False) +class CoolingFeedBulkDeleteView(generic.BulkDeleteView): + queryset = CoolingFeed.objects.all() + filterset = filtersets.CoolingFeedFilterSet + table = tables.CoolingFeedTable + + # # Virtual device contexts # @@ -5023,6 +5878,48 @@ class MACAddressDeleteView(generic.ObjectDeleteView): queryset = MACAddress.objects.all() +@register_model_view(MACAddress, 'set_primary') +class MACAddressSetPrimaryView(ConditionalLoginRequiredMixin, GetReturnURLMixin, View): + queryset = MACAddress.objects.all() + + def get(self, request, pk): + # Degrade a direct GET (bookmark, prefetch) to the MAC's detail page rather than a 405, + # matching DataSourceSyncView. + mac = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk) + return redirect(mac.get_absolute_url()) + + def post(self, request, pk): + mac = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk) + assigned_object = mac.assigned_object + + if assigned_object is None: + messages.error(request, _('This MAC address is not assigned to an interface.')) + return redirect(mac.get_absolute_url()) + + # Re-fetch the interface through its change-restricted queryset so object-level permissions + # are enforced, not just the model-level change permission. + model = assigned_object._meta.model + interface = model.objects.restrict(request.user, 'change').filter(pk=assigned_object.pk).first() + if interface is None: + messages.error( + request, + _('You do not have permission to modify {object}.').format(object=assigned_object) + ) + return redirect(mac.get_absolute_url()) + + try: + interface.set_primary_mac_address(mac) + except ValidationError as e: + messages.error(request, ', '.join(e.messages)) + return redirect(mac.get_absolute_url()) + + messages.success( + request, + _('Set {mac} as primary MAC address for {interface}.').format(mac=mac, interface=interface) + ) + return redirect(self.get_return_url(request, interface)) + + @register_model_view(MACAddress, 'bulk_import', path='import', detail=False) class MACAddressBulkImportView(generic.BulkImportView): queryset = MACAddress.objects.all() diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index 9c9cb146e..238890bc8 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -1,3 +1,4 @@ +from django.core.exceptions import ValidationError as DjangoValidationError from django.utils.translation import gettext as _ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field @@ -8,6 +9,7 @@ from extras.choices import CustomFieldTypeChoices from extras.constants import CUSTOMFIELD_EMPTY_VALUES from extras.models import CustomField from utilities.api import get_serializer_for_model +from utilities.forms.fields import LaxURLField # # Custom fields @@ -74,6 +76,8 @@ class CustomFieldsDataField(Field): elif value is not None and cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: serializer = get_serializer_for_model(cf.related_object_type.model_class()) value = serializer(value, nested=True, many=True, context=self.parent.context).data + elif cf.type in (CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT): + value = cf.resolve_selection_value(value) data[cf.name] = value return data @@ -109,6 +113,15 @@ class CustomFieldsDataField(Field): else: raise ValidationError(_("Unknown related object(s): {name}").format(name=data[cf.name])) + # Normalize URL values the same way the UI does (LaxURLField with assume_scheme='https'), so a + # schemeless value (e.g. "example.com") is stored as an absolute URL ("https://example.com"). + # Malformed values are left untouched for CustomField.validate() to report. + elif cf.type == CustomFieldTypeChoices.TYPE_URL and isinstance(data.get(cf.name), str) and data[cf.name]: + try: + data[cf.name] = LaxURLField(assume_scheme='https').to_python(data[cf.name]) + except DjangoValidationError: + pass + # If updating an existing instance, start with existing custom_field_data if self.parent.instance: data = {**self.parent.instance.custom_field_data, **data} diff --git a/netbox/extras/api/mixins.py b/netbox/extras/api/mixins.py index ce5a509b1..bafef0db2 100644 --- a/netbox/extras/api/mixins.py +++ b/netbox/extras/api/mixins.py @@ -30,24 +30,22 @@ class SharedObjectQuerySetMixin: class ConfigContextQuerySetMixin: """ - Used by views that work with config context models (device and virtual machine). - Provides a get_queryset() method which deals with adding the config context - data annotation or not. + Used by viewsets for config context models (Device, VirtualMachine). + + For non-brief requests, annotates the queryset so that config context data is computed in a + single query for any object whose pre-rendered cache (`_config_context_data`) has been + invalidated (NULL). Objects with a warm cache are served directly from it by + ConfigContextModel.get_config_context() and incur no subquery — PostgreSQL short-circuits the + CASE, so the correlated aggregation runs only for the invalidated rows. This avoids the + per-object fallback query that would otherwise occur when listing objects with cold caches + (e.g. immediately following an upgrade or a broad invalidation). """ def get_queryset(self): - """ - Build the proper queryset based on the request context - - If the `brief` query param equates to True or the `exclude` query param - includes `config_context` as a value, return the base queryset. - - Else, return the queryset annotated with config context data - """ queryset = super().get_queryset() - request = self.get_serializer_context()['request'] - if self.brief or 'config_context' in request.query_params.get('exclude', []): + # Brief responses omit config_context entirely, so the annotation would be pure overhead. + if self.brief: return queryset - return queryset.annotate_config_context_data() + return queryset.annotate_config_context_data(only_invalidated=True) class ConfigTemplateRenderMixin: diff --git a/netbox/extras/api/serializers_/customfields.py b/netbox/extras/api/serializers_/customfields.py index 1cdbc46dd..910f3862f 100644 --- a/netbox/extras/api/serializers_/customfields.py +++ b/netbox/extras/api/serializers_/customfields.py @@ -63,15 +63,18 @@ class CustomFieldSerializer(OwnerMixin, ChangeLogMessageSerializer, ValidatedMod ) ui_visible = ChoiceField(choices=CustomFieldUIVisibleChoices, required=False) ui_editable = ChoiceField(choices=CustomFieldUIEditableChoices, required=False) + # A field is live only while active; the remaining states report a pending bulk update of its + # stored data. Read-only: the state is driven by the responsible background job. + status = ChoiceField(choices=CustomFieldStatusChoices, read_only=True) class Meta: model = CustomField fields = [ 'id', 'url', 'display_url', 'display', 'object_types', 'type', 'related_object_type', 'data_type', 'name', 'label', 'group_name', 'description', 'required', 'unique', 'search_weight', 'filter_logic', - 'ui_visible', 'ui_editable', 'is_cloneable', 'default', 'related_object_filter', 'weight', + 'ui_visible', 'ui_editable', 'is_cloneable', 'nulls_first', 'default', 'related_object_filter', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'validation_schema', 'choice_set', - 'owner', 'comments', 'created', 'last_updated', + 'status', 'owner', 'comments', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/extras/api/serializers_/events.py b/netbox/extras/api/serializers_/events.py index 0d72874e7..697eb50dd 100644 --- a/netbox/extras/api/serializers_/events.py +++ b/netbox/extras/api/serializers_/events.py @@ -1,9 +1,12 @@ +from rest_framework import serializers + from core.models import ObjectType from extras.choices import * from extras.models import EventRule, Webhook from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.gfk_fields import GFKSerializerField from netbox.api.serializers import NetBoxModelSerializer +from netbox.event_rules import get_event_rule_action_choices from users.api.serializers_.mixins import OwnerMixin __all__ = ( @@ -21,21 +24,34 @@ class EventRuleSerializer(OwnerMixin, NetBoxModelSerializer): queryset=ObjectType.objects.with_feature('event_rules'), many=True ) - action_type = ChoiceField(choices=EventRuleActionChoices) + action_type = ChoiceField(choices=[]) # Choices are set by get_fields() action_object_type = ContentTypeField( - queryset=ObjectType.objects.with_feature('event_rules'), + queryset=ObjectType.objects.all(), + required=False, + allow_null=True, ) action_object = GFKSerializerField(read_only=True) + action_is_available = serializers.BooleanField(read_only=True) class Meta: model = EventRule fields = [ 'id', 'url', 'display_url', 'display', 'object_types', 'name', 'enabled', 'event_types', 'conditions', - 'action_type', 'action_object_type', 'action_object_id', 'action_object', 'description', 'custom_fields', - 'owner', 'tags', 'created', 'last_updated', + 'action_type', 'action_object_type', 'action_object_id', 'action_object', 'action_is_available', + 'description', 'custom_fields', 'owner', 'tags', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'name', 'description') + def get_fields(self): + fields = super().get_fields() + + # Rebuild action_type from the live registry on each instantiation to ensure all registered + # actions are captured as choices. + if 'action_type' in fields: + fields['action_type'] = ChoiceField(choices=get_event_rule_action_choices()) + + return fields + # # Webhooks @@ -48,6 +64,6 @@ class WebhookSerializer(OwnerMixin, NetBoxModelSerializer): fields = [ 'id', 'url', 'display_url', 'display', 'name', 'description', 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', 'ssl_verification', 'ca_file_path', - 'custom_fields', 'owner', 'tags', 'created', 'last_updated', + 'timeout', 'custom_fields', 'owner', 'tags', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/extras/apps.py b/netbox/extras/apps.py index 8aad97cd2..afa5b91e7 100644 --- a/netbox/extras/apps.py +++ b/netbox/extras/apps.py @@ -5,9 +5,16 @@ class ExtrasConfig(AppConfig): name = "extras" def ready(self): + from netbox.event_rules import register_event_rule_action from netbox.models.features import register_models from . import dashboard, lookups, search, signals # noqa: F401 + from .event_rules import NotificationAction, ScriptAction, WebhookAction # Register models register_models(*self.get_models()) + + # Register core event rule actions + register_event_rule_action(WebhookAction, is_plugin_provided=False) + register_event_rule_action(ScriptAction, is_plugin_provided=False) + register_event_rule_action(NotificationAction, is_plugin_provided=False) diff --git a/netbox/extras/cache.py b/netbox/extras/cache.py new file mode 100644 index 000000000..dcc9d8a86 --- /dev/null +++ b/netbox/extras/cache.py @@ -0,0 +1,171 @@ +""" +Invalidation helpers for the pre-rendered config-context cache on Device and VirtualMachine. + +Every signal handler in extras/signals.py that needs to invalidate cached config-context data +funnels through this module, so the synchronous NULL-out and background job enqueue are +expressed in exactly one place. +""" +from django.apps import apps +from django.db import router, transaction +from django.db.models import F, Q + +from dcim.models import Device +from extras.jobs import RenderConfigContextJob +from extras.models.tags import TaggedItem +from utilities.querysets import chunked_update +from virtualization.models import VirtualMachine + + +def invalidate_config_context_for_objects(model_label, pks, using=None): + """ + Synchronously NULL the `_config_context_data` cache on the given objects (bumping the + generation counter so an in-flight render can't overwrite the invalidation), then enqueue a + background job to repopulate them once the surrounding transaction commits. + + Args: + model_label: 'dcim.device' or 'virtualization.virtualmachine'. + pks: Any iterable of object PKs (queryset, list, set, generator). An empty iterable is a no-op. + using: The database alias to pin the invalidation to. Callers pass the alias supplied by the + signal which triggered the invalidation, so that the UPDATE lands in the same database + (and the same transaction) as the change which necessitated it. None defers to the + router, as an unpinned query would. + """ + pks = list(pks) + if not pks: + return + + Model = apps.get_model(model_label) + # Resolve the alias once, so that the UPDATE below and the on_commit() callback which follows + # it are bound to the same database. Left as None the two would diverge: an unpinned queryset + # consults the router, but transaction.on_commit() does not -- it attaches to 'default' -- so + # on a deployment whose router writes elsewhere the callback would be registered against a + # connection other than the one being written. + # + # This also decides which connection's commit the enqueue waits on, so it is not strictly an + # improvement on the previous 'default' binding for every caller: one which passes no alias + # while holding a transaction opened on 'default' (rather than on the router's write alias) + # leaves no atomic block open on the resolved alias, and on_commit() then runs the callback + # immediately rather than deferring it. Every caller in NetBox supplies `using`, and the + # generic views open their atomic block on router.db_for_write(model), so the two agree there. + using = using or router.db_for_write(Model) + updated = chunked_update( + Model.objects.using(using).filter(pk__in=pks), + _config_context_data=None, + _config_context_generation=F('_config_context_generation') + 1, + ) + if not updated: + return + + # Defer enqueue until after the current transaction commits, so the background worker doesn't + # try to read uncommitted state. transaction.on_commit() is a no-op outside a transaction, + # in which case the callback runs immediately. + # + # We deliberately enqueue a *parameterless* sweep (model_label=None, pks=None) that re-renders + # every object whose cache is currently NULL, rather than a job scoped to these specific PKs. + # JobRunner.enqueue_once() coalesces against any already-pending job of the same class (object_id + # is NULL for all of these, so get_jobs(None) matches them all); a job carrying a specific PKs + # list would therefore be silently dropped whenever another invalidation already had one enqueued, + # leaving those objects NULLed but never re-rendered. A global NULL-sweep makes coalescing correct: + # whichever sweep runs next picks up *all* outstanding NULL caches across both models. (Reads + # remain correct in the interim because get_config_context() renders on demand when the cache is + # NULL; the sweep only restores the pre-rendered fast path.) + transaction.on_commit( + lambda: RenderConfigContextJob.enqueue_once(instance=None), + using=using, + ) + + +def invalidate_config_context_for_configcontext(configcontext, using=None): + """ + Invalidate caches for all objects currently in scope for the given ConfigContext. `using` is + the database alias to pin every query to (see invalidate_config_context_for_objects()). + """ + for queryset in configcontext.get_affected_objects(using=using): + invalidate_config_context_for_objects( + queryset.model._meta.label_lower, + queryset.values_list('pk', flat=True), + using=using, + ) + + +def invalidate_for_scope_delta(scope_field, scope_pks, using=None): + """ + Invalidate the cache of every Device/VirtualMachine that is matchable via the given scope + items, regardless of which ConfigContext those items belong to. Used when items are removed + from a ConfigContext scope (so we don't know the new affected set under that scope, only the + items that used to extend it). + + `scope_field` is the ConfigContext M2M attribute name ('sites', 'regions', 'tags', ...). + `scope_pks` is the iterable of PKs of scope items that were removed/cleared. + `using` is the database alias to pin every query to (see invalidate_config_context_for_objects()). + """ + scope_pks = list(scope_pks or ()) + if not scope_pks: + return + + device_q = None + vm_q = None + + # Nested (ltree) scopes: any device/VM whose corresponding attribute resolves into the subtree + # of any of the changed items (descendant-or-equal). + nested_attrs = { + 'regions': ('dcim', 'Region', 'site__region__path'), + 'site_groups': ('dcim', 'SiteGroup', 'site__group__path'), + 'roles': ('dcim', 'DeviceRole', 'role__path'), + 'platforms': ('dcim', 'Platform', 'platform__path'), + 'locations': ('dcim', 'Location', 'location__path'), # Devices only + } + direct_attrs = { + 'sites': 'site__in', + 'cluster_types': 'cluster__type__in', + 'cluster_groups': 'cluster__group__in', + 'clusters': 'cluster__in', + 'tenant_groups': 'tenant__group__in', + 'tenants': 'tenant__in', + 'device_types': 'device_type__in', # Devices only + } + + if scope_field in nested_attrs: + app, model_name, object_path = nested_attrs[scope_field] + Model = apps.get_model(app, model_name) + subtree_q = Q() + for path in Model.objects.using(using).filter(pk__in=scope_pks).values_list('path', flat=True): + subtree_q |= Q(**{f'{object_path}__descendant_or_equal': path}) + if not subtree_q: + return + device_q = subtree_q + if scope_field != 'locations': + vm_q = subtree_q + elif scope_field in direct_attrs: + attr_path = direct_attrs[scope_field] + device_q = Q(**{attr_path: scope_pks}) + if scope_field != 'device_types': + vm_q = Q(**{attr_path: scope_pks}) + elif scope_field == 'tags': + device_tagged = TaggedItem.objects.using(using).filter( + tag_id__in=scope_pks, + content_type__app_label='dcim', + content_type__model='device', + ).values_list('object_id', flat=True) + vm_tagged = TaggedItem.objects.using(using).filter( + tag_id__in=scope_pks, + content_type__app_label='virtualization', + content_type__model='virtualmachine', + ).values_list('object_id', flat=True) + device_q = Q(pk__in=device_tagged) + vm_q = Q(pk__in=vm_tagged) + else: + return + + if device_q is not None: + invalidate_config_context_for_objects( + 'dcim.device', + Device.objects.using(using).filter(device_q).values_list('pk', flat=True), + using=using, + ) + if vm_q is not None: + invalidate_config_context_for_objects( + 'virtualization.virtualmachine', + VirtualMachine.objects.using(using).filter(vm_q).values_list('pk', flat=True), + using=using, + ) diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index b011819d4..6be6756cf 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -3,7 +3,7 @@ import logging from django.utils.translation import gettext_lazy as _ from netbox.choices import ButtonColorChoices -from utilities.choices import ChoiceSet +from utilities.choices import Choice, ChoiceSet # # CustomFields @@ -27,22 +27,52 @@ class CustomFieldTypeChoices(ChoiceSet): TYPE_MULTIOBJECT = 'multiobject' CHOICES = ( - (TYPE_TEXT, _('Text')), - (TYPE_LONGTEXT, _('Text (long)')), - (TYPE_INTEGER, _('Integer')), - (TYPE_DECIMAL, _('Decimal')), - (TYPE_BOOLEAN, _('Boolean (true/false)')), - (TYPE_DATE, _('Date')), - (TYPE_DATETIME, _('Date & time')), - (TYPE_URL, _('URL')), - (TYPE_JSON, _('JSON')), - (TYPE_SELECT, _('Selection')), - (TYPE_MULTISELECT, _('Multiple selection')), - (TYPE_OBJECT, _('Object')), - (TYPE_MULTIOBJECT, _('Multiple objects')), + Choice(TYPE_TEXT, _('Text'), description=_('A single line of text')), + Choice(TYPE_LONGTEXT, _('Text (long)'), description=_('Multi-line text with Markdown support')), + Choice(TYPE_INTEGER, _('Integer'), description=_('A whole number (positive or negative)')), + Choice(TYPE_DECIMAL, _('Decimal'), description=_('A fixed-precision decimal number')), + Choice(TYPE_BOOLEAN, _('Boolean'), description=_('A true or false value')), + Choice(TYPE_DATE, _('Date'), description=_('A calendar date')), + Choice(TYPE_DATETIME, _('Date & time'), description=_('A calendar date and time')), + Choice(TYPE_URL, _('URL'), description=_('A hyperlink to an external resource')), + Choice(TYPE_JSON, _('JSON'), description=_('Arbitrary data encoded as JSON')), + Choice(TYPE_SELECT, _('Selection'), description=_('A single value chosen from a predefined list')), + Choice( + TYPE_MULTISELECT, + _('Multiple selection'), + description=_('One or more values chosen from a predefined list') + ), + Choice(TYPE_OBJECT, _('Object'), description=_('A reference to a single NetBox object')), + Choice(TYPE_MULTIOBJECT, _('Multiple objects'), description=_('References to one or more NetBox objects')), ) +class CustomFieldStatusChoices(ChoiceSet): + """ + The lifecycle state of a CustomField. + + A field participates in object data only while active. The remaining states indicate that a bulk + update of its stored data is pending or in progress, during which the field is not live but its + row continues to reserve the field's name. + """ + STATUS_ACTIVE = 'active' + STATUS_PROVISIONING = 'provisioning' + STATUS_DELETING = 'deleting' + + CHOICES = ( + (STATUS_ACTIVE, _('Active'), 'green'), + (STATUS_PROVISIONING, _('Provisioning'), 'cyan'), + (STATUS_DELETING, _('Deleting'), 'red'), + ) + + # The statuses in which the field's stored object data is its own: an active field's data is + # live, and a provisioning field's is being written by the job which will bring it live. Data + # held for a field in one of these statuses is left alone when an object is saved, and its + # default is populated on objects which lack it. A deleting field's data is on its way out, and + # so is excluded (see CustomFieldsMixin.clean() and get_defaults_for_model()). + DATA_STATUSES = (STATUS_ACTIVE, STATUS_PROVISIONING) + + class CustomFieldFilterLogicChoices(ChoiceSet): FILTER_DISABLED = 'disabled' @@ -50,9 +80,9 @@ class CustomFieldFilterLogicChoices(ChoiceSet): FILTER_EXACT = 'exact' CHOICES = ( - (FILTER_DISABLED, _('Disabled')), - (FILTER_LOOSE, _('Loose')), - (FILTER_EXACT, _('Exact')), + Choice(FILTER_DISABLED, _('Disabled'), description=_('The field cannot be used for filtering')), + Choice(FILTER_LOOSE, _('Loose'), description=_('Match on a partial value (case-insensitive substring)')), + Choice(FILTER_EXACT, _('Exact'), description=_('Match on the exact value')), ) @@ -63,9 +93,9 @@ class CustomFieldUIVisibleChoices(ChoiceSet): HIDDEN = 'hidden' CHOICES = ( - (ALWAYS, _('Always'), 'green'), - (IF_SET, _('If set'), 'yellow'), - (HIDDEN, _('Hidden'), 'gray'), + Choice(ALWAYS, _('Always'), color='green', description=_('Always display the field')), + Choice(IF_SET, _('If set'), color='yellow', description=_('Display the field only if it has a value')), + Choice(HIDDEN, _('Hidden'), color='gray', description=_('Never display the field')), ) @@ -76,9 +106,9 @@ class CustomFieldUIEditableChoices(ChoiceSet): HIDDEN = 'hidden' CHOICES = ( - (YES, _('Yes'), 'green'), - (NO, _('No'), 'red'), - (HIDDEN, _('Hidden'), 'gray'), + Choice(YES, _('Yes'), color='green', description=_('The field value can be edited by users')), + Choice(NO, _('No'), color='red', description=_('The field is displayed but cannot be edited')), + Choice(HIDDEN, _('Hidden'), color='gray', description=_('The field is neither displayed nor editable')), ) @@ -89,9 +119,9 @@ class CustomFieldChoiceSetBaseChoices(ChoiceSet): UN_LOCODE = 'UN_LOCODE' CHOICES = ( - (IATA, 'IATA (Airport codes)'), - (ISO_3166, 'ISO 3166 (Country codes)'), - (UN_LOCODE, 'UN/LOCODE (Location codes)'), + Choice(IATA, 'IATA (Airport codes)'), + Choice(ISO_3166, 'ISO 3166 (Country codes)'), + Choice(UN_LOCODE, 'UN/LOCODE (Location codes)'), ) @@ -112,19 +142,19 @@ class CustomFieldChoiceColorChoices(ChoiceSet): WHITE = 'white' CHOICES = ( - (BLUE, _('Blue'), BLUE), - (INDIGO, _('Indigo'), INDIGO), - (PURPLE, _('Purple'), PURPLE), - (PINK, _('Pink'), PINK), - (RED, _('Red'), RED), - (ORANGE, _('Orange'), ORANGE), - (YELLOW, _('Yellow'), YELLOW), - (GREEN, _('Green'), GREEN), - (TEAL, _('Teal'), TEAL), - (CYAN, _('Cyan'), CYAN), - (GRAY, _('Gray'), GRAY), - (BLACK, _('Black'), BLACK), - (WHITE, _('White'), WHITE), + Choice(BLUE, _('Blue'), color=BLUE), + Choice(INDIGO, _('Indigo'), color=INDIGO), + Choice(PURPLE, _('Purple'), color=PURPLE), + Choice(PINK, _('Pink'), color=PINK), + Choice(RED, _('Red'), color=RED), + Choice(ORANGE, _('Orange'), color=ORANGE), + Choice(YELLOW, _('Yellow'), color=YELLOW), + Choice(GREEN, _('Green'), color=GREEN), + Choice(TEAL, _('Teal'), color=TEAL), + Choice(CYAN, _('Cyan'), color=CYAN), + Choice(GRAY, _('Gray'), color=GRAY), + Choice(BLACK, _('Black'), color=BLACK), + Choice(WHITE, _('White'), color=WHITE), ) @@ -138,7 +168,7 @@ class CustomLinkButtonClassChoices(ButtonColorChoices): CHOICES = ( *ButtonColorChoices.CHOICES, - (LINK, _('Link')), + Choice(LINK, _('Link'), description=_('Render the button as a borderless text link')), ) @@ -154,10 +184,10 @@ class BookmarkOrderingChoices(ChoiceSet): ORDERING_ALPHABETICAL_ZA = '-name' CHOICES = ( - (ORDERING_NEWEST, _('Newest')), - (ORDERING_OLDEST, _('Oldest')), - (ORDERING_ALPHABETICAL_AZ, _('Alphabetical (A-Z)')), - (ORDERING_ALPHABETICAL_ZA, _('Alphabetical (Z-A)')), + Choice(ORDERING_NEWEST, _('Newest')), + Choice(ORDERING_OLDEST, _('Oldest')), + Choice(ORDERING_ALPHABETICAL_AZ, _('Alphabetical (A-Z)')), + Choice(ORDERING_ALPHABETICAL_ZA, _('Alphabetical (Z-A)')), ) @@ -174,10 +204,10 @@ class JournalEntryKindChoices(ChoiceSet): KIND_DANGER = 'danger' CHOICES = [ - (KIND_INFO, _('Info'), 'cyan'), - (KIND_SUCCESS, _('Success'), 'green'), - (KIND_WARNING, _('Warning'), 'yellow'), - (KIND_DANGER, _('Danger'), 'red'), + Choice(KIND_INFO, _('Info'), color='cyan', description=_('An informational entry')), + Choice(KIND_SUCCESS, _('Success'), color='green', description=_('A record of a successful outcome')), + Choice(KIND_WARNING, _('Warning'), color='yellow', description=_('A cautionary note requiring attention')), + Choice(KIND_DANGER, _('Danger'), color='red', description=_('A record of a critical issue or failure')), ] @@ -194,11 +224,11 @@ class LogLevelChoices(ChoiceSet): LOG_FAILURE = 'failure' CHOICES = ( - (LOG_DEBUG, _('Debug'), 'teal'), - (LOG_INFO, _('Info'), 'cyan'), - (LOG_SUCCESS, _('Success'), 'green'), - (LOG_WARNING, _('Warning'), 'yellow'), - (LOG_FAILURE, _('Failure'), 'red'), + Choice(LOG_DEBUG, _('Debug'), color='teal'), + Choice(LOG_INFO, _('Info'), color='cyan'), + Choice(LOG_SUCCESS, _('Success'), color='green'), + Choice(LOG_WARNING, _('Warning'), color='yellow'), + Choice(LOG_FAILURE, _('Failure'), color='red'), ) @@ -224,11 +254,11 @@ class WebhookHttpMethodChoices(ChoiceSet): METHOD_DELETE = 'DELETE' CHOICES = ( - (METHOD_GET, 'GET'), - (METHOD_POST, 'POST'), - (METHOD_PUT, 'PUT'), - (METHOD_PATCH, 'PATCH'), - (METHOD_DELETE, 'DELETE'), + Choice(METHOD_GET, 'GET'), + Choice(METHOD_POST, 'POST'), + Choice(METHOD_PUT, 'PUT'), + Choice(METHOD_PATCH, 'PATCH'), + Choice(METHOD_DELETE, 'DELETE'), ) @@ -252,19 +282,19 @@ class DashboardWidgetColorChoices(ChoiceSet): 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')), + Choice(BLUE, _('Blue')), + Choice(INDIGO, _('Indigo')), + Choice(PURPLE, _('Purple')), + Choice(PINK, _('Pink')), + Choice(RED, _('Red')), + Choice(ORANGE, _('Orange')), + Choice(YELLOW, _('Yellow')), + Choice(GREEN, _('Green')), + Choice(TEAL, _('Teal')), + Choice(CYAN, _('Cyan')), + Choice(GRAY, _('Gray')), + Choice(BLACK, _('Black')), + Choice(WHITE, _('White')), ) @@ -272,14 +302,14 @@ class DashboardWidgetColorChoices(ChoiceSet): # Event Rules # -class EventRuleActionChoices(ChoiceSet): +class EventRuleActionChoices: + """ + The slugs of NetBox's built-in event rule actions. Not a ChoiceSet: the full, current set of + valid action_type values is plugin-extensible and lives in the netbox.event_rules registry, + not here -- see get_event_rule_action_choices(). Use these constants for the three built-in + actions; do not pass this class itself as a Django/DRF field's `choices=`. + """ WEBHOOK = 'webhook' SCRIPT = 'script' NOTIFICATION = 'notification' - - CHOICES = ( - (WEBHOOK, _('Webhook')), - (SCRIPT, _('Script')), - (NOTIFICATION, _('Notification')), - ) diff --git a/netbox/extras/conditions.py b/netbox/extras/conditions.py index bcf005947..7f4a16cd1 100644 --- a/netbox/extras/conditions.py +++ b/netbox/extras/conditions.py @@ -1,10 +1,9 @@ -import functools -import operator import re from django.utils.translation import gettext as _ __all__ = ( + 'AbsentData', 'Condition', 'ConditionSet', 'InvalidCondition', @@ -13,6 +12,64 @@ __all__ = ( AND = 'and' OR = 'or' +# Prefix identifying a condition attribute that reads an event's pre- or post-change snapshot directly, e.g. +# 'snapshots.prechange.status'. +SNAPSHOT_PREFIX = 'snapshots.' + +# Maps each snapshot to its counterpart +OPPOSITE_SNAPSHOT = { + 'prechange': 'postchange', + 'postchange': 'prechange', +} + +# Sentinel for a snapshot attribute that could not be resolved (missing key or null snapshot) +_MISSING = object() + + +class AbsentData(dict): + """ + An empty dict standing in for an event payload which cannot be evaluated: one which is + absent (a job which recorded no data) or unusable (a payload which is not a dict at all). + """ + def copy(self): + # dict.copy() would return a plain dict, silently discarding the marker. + return AbsentData(self) + + +def walk_path(obj, keys, empty_list_is_absent=False): + """ + Walk a sequence of keys through obj, returning _MISSING if a key is absent or null along the way. + + Raises TypeError if the path descends into a value which cannot be indexed by key (e.g. a + REST API-style 'status.value' applied to a snapshot, where status is the raw string + "active"). Walkability follows from the value's type: an empty string is as unwalkable as any + other scalar, not an absent key. + """ + for key in keys: + if obj is None: + return _MISSING + if isinstance(obj, list): + if not obj and empty_list_is_absent: + # An empty list yields no evidence either way + return _MISSING + values = [] + for item in obj: + if item is None: + return _MISSING + if not isinstance(item, dict): + raise TypeError(f"cannot resolve '{key}' within {type(item).__name__}") + if key not in item: + return _MISSING + values.append(item[key]) + obj = values + elif isinstance(obj, dict): + if key not in obj: + return _MISSING + obj = obj[key] + else: + raise TypeError(f"cannot resolve '{key}' within {type(obj).__name__}") + return obj + def is_ruleset(data): """ @@ -30,8 +87,9 @@ class Condition: An individual conditional rule that evaluates a single attribute and its value. :param attr: The name of the attribute being evaluated - :param value: The value being compared + :param value: The value being compared (not used by snapshot operators) :param op: The logical operation to use when evaluating the value (default: 'eq') + :param negate: Invert the result of evaluation """ EQ = 'eq' GT = 'gt' @@ -41,11 +99,16 @@ class Condition: IN = 'in' CONTAINS = 'contains' REGEX = 'regex' + CHANGED = 'changed' + UNCHANGED = 'unchanged' OPERATORS = ( - EQ, GT, GTE, LT, LTE, IN, CONTAINS, REGEX + EQ, GT, GTE, LT, LTE, IN, CONTAINS, REGEX, CHANGED, UNCHANGED ) + # Operators that compare pre/post snapshots and do not accept a value. + SNAPSHOT_OPERATORS = (CHANGED, UNCHANGED) + TYPES = { str: (EQ, CONTAINS, REGEX), bool: (EQ, CONTAINS), @@ -55,39 +118,191 @@ class Condition: type(None): (EQ,) } - def __init__(self, attr, value, op=EQ, negate=False): + def __init__(self, attr, value=_MISSING, op=EQ, negate=False): if op not in self.OPERATORS: raise ValueError(_("Unknown operator: {op}. Must be one of: {operators}").format( op=op, operators=', '.join(self.OPERATORS) )) - if type(value) not in self.TYPES: - raise ValueError(_("Unsupported value type: {value}").format(value=type(value))) - if op not in self.TYPES[type(value)]: - raise ValueError(_("Invalid type for {op} operation: {value}").format(op=op, value=type(value))) + + if op in self.SNAPSHOT_OPERATORS: + if value is not _MISSING: + raise ValueError(_( + "The '{op}' operator compares snapshots and does not accept a value." + ).format(op=op)) + if attr.startswith(SNAPSHOT_PREFIX): + raise ValueError(_( + "The '{op}' operator resolves '{attr}' within each snapshot dict, not the " + "top-level condition context. Use the bare attribute name (e.g. 'status') " + "rather than a snapshot path (e.g. 'snapshots.prechange.status'), which is " + "only valid with standard operators." + ).format(op=op, attr=attr)) + self.value = _MISSING + else: + if value is _MISSING: + raise ValueError(_("A value is required for the '{op}' operator.").format(op=op)) + if type(value) not in self.TYPES: + raise ValueError(_("Unsupported value type: {value}").format(value=type(value))) + if op not in self.TYPES[type(value)]: + raise ValueError(_("Invalid type for {op} operation: {value}").format(op=op, value=type(value))) + self.value = value self.attr = attr - self.value = value self.op = op self.eval_func = getattr(self, f'eval_{op}') self.negate = negate + def _resolve_attr(self, data): + """ + Walk self.attr as a dotted key path through data. Raises InvalidCondition on + missing keys, or when an intermediate value can't be indexed by key (e.g. a + REST API-style path like 'status.value' applied to a raw snapshot value). + """ + try: + value = walk_path(data, self.attr.split('.')) + except TypeError as e: + raise InvalidCondition(f"Invalid key path: {self.attr} ({e})") + if value is _MISSING: + raise InvalidCondition(f"Invalid key path: {self.attr}") + return value + + def _references_absent_payload(self, data): + """ + Return True if self.attr references an attribute of a payload which is absent + altogether (AbsentData), as opposed to one which is present but lacks the attribute. + """ + return isinstance(data, AbsentData) and self.attr.split('.')[0] not in data + + def _references_absent_snapshot(self, data): + """ + Return True if self.attr is a direct snapshot path (snapshots.prechange.* or + snapshots.postchange.*) whose snapshot is null and whose remaining path the opposite + snapshot resolves. Create events have no prechange snapshot, delete events no + postchange snapshot. + + Unlike an absent payload, such a reference resolves to null: the snapshot's absence is + itself meaningful (the object did not exist before, or does not after), and validating + the path below shows the reference to describe the data. + """ + if not self.attr.startswith(SNAPSHOT_PREFIX): + return False + snapshots = data.get('snapshots') if isinstance(data, dict) else None + if type(snapshots) is not dict: + return False + which, _sep, remainder = self.attr[len(SNAPSHOT_PREFIX):].partition('.') + if which not in OPPOSITE_SNAPSHOT: + # Anything other than prechange or postchange names no snapshot the event could have + # recorded, so the path does not describe the data + return False + if which not in snapshots or snapshots[which] is not None: + return False + + # The referenced snapshot is null, which excuses only data the event would otherwise + # have carried, never a path which does not describe the data. Validate the remainder + # against the opposite snapshot so that such a path fails closed here exactly as it does + # when both snapshots are present; otherwise a typo would resolve to null and fire the + # rule on every create or delete, with nothing logged. + other = snapshots.get(OPPOSITE_SNAPSHOT[which]) + if remainder and other is not None: + try: + value = walk_path(other, remainder.split('.'), empty_list_is_absent=True) + except TypeError: + return False + if value is _MISSING: + return False + + # Nothing to validate against: with the opposite snapshot absent too, the event carries + # no data anywhere for the path to be checked. Testing for the absent snapshot itself + # (snapshots.prechange, no remainder) lands here too. + return True + + def _resolve_snapshot_attrs(self, snapshots): + """ + Walk self.attr through the prechange and postchange snapshots, returning the two + resolved values, with _MISSING for a snapshot which is absent, lacks the attribute, or + cannot be walked by the path. + + Raises InvalidCondition if the attribute resolves in neither snapshot, leaving nothing + to compare: a misspelling, an unwalkable path, or an event which recorded no snapshots. + The unresolved state must be reported rather than compared, since any boolean it + returned would become a match under negate. + + A path which resolves in only one snapshot describes a real difference between them (a + JSON attribute whose value changed shape, say), so the unresolved side counts as missing + and the comparison proceeds: raising would report as unchanged an attribute which + demonstrably changed. Only a snapshot yielding a value excuses the other side; one + resolving to nothing is no evidence that the path describes the data. + """ + keys = self.attr.split('.') + values = [] + errors = [] + available = False + resolved = False + + for which in ('prechange', 'postchange'): + snapshot = snapshots.get(which) + if snapshot is None: + # Absent snapshot (normal for create and delete events): nothing to resolve + values.append(_MISSING) + continue + available = True + try: + value = walk_path(snapshot, keys, empty_list_is_absent=True) + except TypeError as e: + values.append(_MISSING) + errors.append(e) + else: + values.append(value) + resolved = resolved or value is not _MISSING + + if not available: + # Neither snapshot was recorded, so the attribute itself is not in question + raise InvalidCondition( + f"No snapshot data available for '{self.op}' operator: {self.attr}. " + f"Snapshot operators are only meaningful on update and delete events." + ) + if not resolved: + reason = f" ({errors[0]})" if errors else "" + raise InvalidCondition( + f"Invalid key path for '{self.op}' operator: {self.attr}{reason}. The attribute resolves in neither " + f"snapshot. Note that snapshots store raw field values, so choice fields have no '.value' suffix." + ) + + return values + def eval(self, data): """ Evaluate the provided data to determine whether it matches the condition. """ - def _get(obj, key): - if isinstance(obj, list): - return [operator.getitem(item or {}, key) for item in obj] - return operator.getitem(obj or {}, key) + if self.op in self.SNAPSHOT_OPERATORS: + snapshots = data.get('snapshots') if isinstance(data, dict) else None + if type(snapshots) is not dict: + raise InvalidCondition( + f"No snapshot data available for '{self.op}' operator. " + f"Snapshot operators are only meaningful on update and delete events." + ) + result = self.eval_func(snapshots) + return not result if self.negate else result - try: - value = functools.reduce(_get, self.attr.split('.'), data) - except KeyError: - raise InvalidCondition(f"Invalid key path: {self.attr}") + if self._references_absent_payload(data): + # No payload to evaluate, so the condition cannot be satisfied. Negation is not + # applied: it inverts the result of a comparison, and none took place - inverting + # would fire the rule on an event which carried nothing to match against. Nor is + # this an invalid condition: a job which records no data is routine, and logging it + # per rule per event would bury the malformed conditions worth acting on. + return False + + absent = self._references_absent_snapshot(data) + value = None if absent else self._resolve_attr(data) try: result = self.eval_func(value) except TypeError as e: - raise InvalidCondition(f"Invalid data type at '{self.attr}' for '{self.op}' evaluation: {e}") + if not absent: + raise InvalidCondition(f"Invalid data type at '{self.attr}' for '{self.op}' evaluation: {e}") + # An absent snapshot resolves to null, which satisfies only a comparison against + # null: contains, regex and the numeric comparisons raise TypeError on None. That is + # a non-match, not a malformed condition, so report False (subject to negation + # below) rather than aborting the condition set. + result = False if self.negate: return not result @@ -128,6 +343,16 @@ class Condition: def eval_regex(self, value): return re.match(self.value, value) is not None + # Snapshot comparison operators + + def eval_changed(self, snapshots): + pre, post = self._resolve_snapshot_attrs(snapshots) + return pre != post + + def eval_unchanged(self, snapshots): + pre, post = self._resolve_snapshot_attrs(snapshots) + return pre == post + class ConditionSet: """ diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index a0a33936b..91487742e 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -6,13 +6,14 @@ from extras.choices import LogLevelChoices # Custom fields CUSTOMFIELD_EMPTY_VALUES = (None, '', []) -# Maximum number of objects to update per query when provisioning, removing, or renaming custom -# field data. Bounding the number of rows touched by each statement prevents very large tables from -# exceeding the database statement timeout (JSONB updates rewrite each affected row). This value -# sits at the throughput "knee": benchmarking jsonb_set() across a 1M-row table showed throughput -# plateaus by ~5K rows/statement (raising it further yields no meaningful speedup), while keeping -# each statement orders of magnitude below a typical statement timeout. -CUSTOMFIELD_DATA_BATCH_SIZE = 5000 +# Timeout (in seconds) applied to the background jobs which provision and purge custom field data. +# These jobs exist precisely because the work is too large for the request which triggered it, so +# the default RQ timeout -- being of the same order as the request timeout being escaped -- would +# reimpose the limit they were introduced to avoid. A timeout is recoverable, as each job commits +# its batches independently and both are idempotent, but it leaves the field pending until the job +# is run again. Three hours is well beyond what a batched update of any real table takes, while +# still releasing a worker blocked on an unresponsive database. +CUSTOMFIELD_JOB_TIMEOUT = 10800 # ImageAttachment IMAGE_ATTACHMENT_IMAGE_FORMATS = { @@ -214,3 +215,15 @@ LOG_LEVEL_RANK = { LogLevelChoices.LOG_WARNING: 3, LogLevelChoices.LOG_FAILURE: 4, } + +# Config context cache: fields whose modification on an object requires re-rendering its config +# context cache, keyed by model label. +CC_FIELDS_BY_MODEL = { + 'dcim.device': ( + 'site_id', 'location_id', 'device_type_id', 'role_id', 'tenant_id', 'platform_id', + 'cluster_id', 'local_context_data', + ), + 'virtualization.virtualmachine': ( + 'site_id', 'cluster_id', 'tenant_id', 'platform_id', 'role_id', 'local_context_data', + ), +} diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index 94ca3012b..4450c6caf 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/netbox/extras/dashboard/widgets.py @@ -17,6 +17,7 @@ from django.utils.translation import gettext as _ from core.models import ObjectType from extras.choices import BookmarkOrderingChoices from netbox.config import get_config +from utilities.choices import Choice from utilities.html import clean_html from utilities.object_types import object_type_identifier, object_type_name from utilities.permissions import get_permission_for_model @@ -42,7 +43,7 @@ logger = logging.getLogger('netbox.data_backends') def get_object_type_choices(): return [ - (object_type_identifier(ot), object_type_name(ot)) + Choice(object_type_identifier(ot), object_type_name(ot)) for ot in ObjectType.objects.public().order_by('app_label', 'model') ] @@ -70,7 +71,7 @@ def object_list_widget_supports_model(model: Model) -> bool: def get_bookmarks_object_type_choices(): return [ - (object_type_identifier(ot), object_type_name(ot)) + Choice(object_type_identifier(ot), object_type_name(ot)) for ot in ObjectType.objects.with_feature('bookmarks').order_by('app_label', 'model') ] diff --git a/netbox/extras/event_rules.py b/netbox/extras/event_rules.py new file mode 100644 index 000000000..60c00c6b5 --- /dev/null +++ b/netbox/extras/event_rules.py @@ -0,0 +1,123 @@ +import logging + +from django.core.exceptions import ValidationError +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from django_rq import get_queue + +from netbox.config import get_config +from netbox.constants import RQ_QUEUE_DEFAULT +from netbox.event_rules import EventRuleAction +from utilities.request import copy_safe_request +from utilities.rqworker import get_rq_retry + +from .choices import EventRuleActionChoices +from .models import NotificationGroup, Script, Webhook + +__all__ = ( + 'NotificationAction', + 'ScriptAction', + 'WebhookAction', +) + +logger = logging.getLogger('netbox.events_processor') + + +class WebhookAction(EventRuleAction): + slug = EventRuleActionChoices.WEBHOOK + label = _('Webhook') + description = _('Send an outgoing HTTP request to a remote endpoint') + object_model = Webhook + object_required = True + + def enqueue(self, *, event_rule, event_context, action_object, action_data): + # Select the appropriate RQ queue + queue_name = get_config().QUEUE_MAPPINGS.get('webhook', RQ_QUEUE_DEFAULT) + rq_queue = get_queue(queue_name) + + # Compile the task parameters + params = { + 'event_rule': event_rule, + 'object_type': event_context['object_type'], + 'event_type': event_context['event_type'], + 'data': action_data, + 'snapshots': event_context.get('snapshots'), + 'timestamp': timezone.now().isoformat(), + 'retry': get_rq_retry(), + } + if 'request' in event_context: + # Exclude FILES - webhooks don't need uploaded files, + # which can cause pickle errors with Pillow. + params['request'] = copy_safe_request(event_context['request'], include_files=False) + + # Enqueue the task + rq_queue.enqueue('extras.webhooks.send_webhook', **params) + + def resolve_import_object(self, value): + return Webhook.objects.get(name=value) + + +class ScriptAction(EventRuleAction): + slug = EventRuleActionChoices.SCRIPT + label = _('Script') + description = _('Execute a custom script') + object_model = Script + object_required = True + + def enqueue(self, *, event_rule, event_context, action_object, action_data): + # Resolve the script from action parameters + script = action_object.python_class() + + # Enqueue a Job to record the script's execution + from extras.jobs import ScriptJob + + params = { + 'instance': action_object, + 'name': script.name, + 'user': event_context['user'], + 'data': action_data, + 'notifications': script.notifications_default, + 'job_timeout': script.job_timeout, + } + if 'snapshots' in event_context: + params['snapshots'] = event_context['snapshots'] + if 'request' in event_context: + params['request'] = copy_safe_request(event_context['request'], include_files=False) + + # Enqueue the job. If the script's Meta configuration is invalid (see #22872), log the error and skip this + # action rather than allowing the exception to abort the event pipeline (and, since events are processed + # in-request, the originating object change). Note this is intentionally asymmetric with the webhook + # action, which lets enqueue failures propagate: script Meta is validated eagerly at enqueue and a + # misconfigured script must not take down an unrelated object change. + try: + ScriptJob.enqueue(**params) + except ValidationError as e: + logger.error( + "Skipping script action for event rule %s: invalid script configuration: %s", + event_rule, '; '.join(e.messages) + ) + + def resolve_import_object(self, value): + from extras.scripts import get_module_and_script + module_name, script_name = value.split('.', 1) + return get_module_and_script(module_name, script_name)[1] + + +class NotificationAction(EventRuleAction): + slug = EventRuleActionChoices.NOTIFICATION + label = _('Notification') + description = _('Generate a notification for one or more users or groups') + object_model = NotificationGroup + object_required = True + + def enqueue(self, *, event_rule, event_context, action_object, action_data): + # Bulk-create notifications for all members of the notification group + action_object.notify( + object_type=event_context['object_type'], + object_id=action_data['id'], + object_repr=action_data.get('display'), + event_type=event_context['event_type'], + ) + + def resolve_import_object(self, value): + return NotificationGroup.objects.get(name=value) diff --git a/netbox/extras/events.py b/netbox/extras/events.py index c4f5aa69d..20cfe41d9 100644 --- a/netbox/extras/events.py +++ b/netbox/extras/events.py @@ -2,23 +2,16 @@ import logging from collections import UserDict, defaultdict from django.conf import settings -from django.core.exceptions import ValidationError -from django.utils import timezone from django.utils.module_loading import import_string from django.utils.translation import gettext as _ -from django_rq import get_queue from core.events import * from core.models import ObjectType -from netbox.config import get_config -from netbox.constants import RQ_QUEUE_DEFAULT from netbox.models.features import has_feature from utilities.api import get_serializer_for_model -from utilities.request import copy_safe_request -from utilities.rqworker import get_rq_retry from utilities.serialization import serialize_object -from .choices import EventRuleActionChoices +from .conditions import AbsentData from .models import EventRule logger = logging.getLogger('netbox.events_processor') @@ -155,9 +148,6 @@ def enqueue_event(queue, instance, request, event_type): snapshots=get_snapshots(instance, event_type), request=request, user=request.user, - # Legacy request attributes for backward compatibility - username=request.user.username, # DEPRECATED, will be removed in NetBox v4.7.0 - request_id=request.id, # DEPRECATED, will be removed in NetBox v4.7.0 ) # For delete events, eagerly serialize the payload before the row is gone. @@ -171,21 +161,39 @@ def process_event_rules(event_rules, object_type, event): Process a list of EventRules against an event. Notes on event sources: - - Object change events (created/updated/deleted) are enqueued via - enqueue_event() during an HTTP request. - These events include a request object and legacy request - attributes (e.g. username, request_id) for backward compatibility. - - Job lifecycle events (JOB_STARTED/JOB_COMPLETED) are emitted by - job_start/job_end signal handlers and may not include a request - context. - Consumers must not assume that fields like `username` are always - present. + - Object change events (created/updated/deleted) are enqueued via enqueue_event() + during an HTTP request. These events include a request object, and their payload is + always the serialized object. + - Job lifecycle events (JOB_STARTED/JOB_COMPLETED) are emitted by job_start/job_end + signal handlers and may not include a request context. Consumers must not assume + that a request is always present. Their payload is the job's `data` field, which is + nullable and (for a job which sets it directly) not guaranteed to be a dict. """ + if not event_rules: + return + + # Normalize object_type onto the event context so that an action's enqueue() can always read + # event_context['object_type']: job-lifecycle events pass it only as this parameter. + event['object_type'] = object_type + + # Normalize the event payload to a dict or AbsentData once for all rules. + data = event['data'] + if not isinstance(data, dict): + if data is not None: + logger.warning( + _('Ignoring invalid data payload on {event_type} event (got {data_type})').format( + event_type=event['event_type'], + data_type=type(data).__name__, + ) + ) + data = AbsentData() for event_rule in event_rules: - # Evaluate event rule conditions (if any) - if not event_rule.eval_conditions(event['data']): + # Merge snapshots and evaluate event rule conditions (if any). + condition_data = data.copy() + condition_data['snapshots'] = event.get('snapshots') + if not event_rule.eval_conditions(condition_data): continue # Guard against action_data that is valid JSON but not a dict @@ -207,88 +215,37 @@ def process_event_rules(event_rules, object_type, event): # Merge rule-specific action_data with the event payload. # Copy to avoid mutating the rule's stored action_data dict. - event_data = {**action_data, **event['data']} + event_data = {**action_data, **data} - # Webhooks - if event_rule.action_type == EventRuleActionChoices.WEBHOOK: - - # Select the appropriate RQ queue - queue_name = get_config().QUEUE_MAPPINGS.get('webhook', RQ_QUEUE_DEFAULT) - rq_queue = get_queue(queue_name) - - # For job lifecycle events, `username` may be absent because - # there is no request context. - # Prefer the associated user object when present, falling - # back to the legacy username attribute. - username = getattr(event.get('user'), 'username', None) or event.get('username') - - # Compile the task parameters - params = { - 'event_rule': event_rule, - 'object_type': object_type, - 'event_type': event['event_type'], - 'data': event_data, - 'snapshots': event.get('snapshots'), - 'timestamp': timezone.now().isoformat(), - 'username': username, - 'retry': get_rq_retry(), - } - if 'request' in event: - # Exclude FILES - webhooks don't need uploaded files, - # which can cause pickle errors with Pillow. - params['request'] = copy_safe_request(event['request'], include_files=False) - - # Enqueue the task - rq_queue.enqueue('extras.webhooks.send_webhook', **params) - - # Scripts - elif event_rule.action_type == EventRuleActionChoices.SCRIPT: - # Resolve the script from action parameters - script = event_rule.action_object.python_class() - - # Enqueue a Job to record the script's execution - from extras.jobs import ScriptJob - - params = { - 'instance': event_rule.action_object, - 'name': script.name, - 'user': event['user'], - 'data': event_data, - 'notifications': script.notifications_default, - 'job_timeout': script.job_timeout, - } - if 'snapshots' in event: - params['snapshots'] = event['snapshots'] - if 'request' in event: - params['request'] = copy_safe_request(event['request'], include_files=False) - - # Enqueue the job. If the script's Meta configuration is invalid (see #22872), log the error and skip this - # action rather than allowing the exception to abort the event pipeline (and, since events are processed - # in-request, the originating object change). Note this is intentionally asymmetric with the webhook - # branch above, which lets enqueue failures propagate: script Meta is validated eagerly at enqueue and a - # misconfigured script must not take down an unrelated object change. - try: - ScriptJob.enqueue(**params) - except ValidationError as e: - logger.error( - "Skipping script action for event rule %s: invalid script configuration: %s", - event_rule, '; '.join(e.messages) + action = event_rule.action_provider + if action is None: + # The plugin providing this action type may not be installed. Log and move on to the + # next rule rather than raising: one rule's unavailable action must not prevent any + # other rule in this batch from being processed. + logger.warning( + _('Skipping event rule "{rule}": action type "{action_type}" is not registered ' + '(the providing plugin may not be installed).').format( + rule=event_rule, action_type=event_rule.action_type, ) - - # Notification groups - elif event_rule.action_type == EventRuleActionChoices.NOTIFICATION: - # Bulk-create notifications for all members of the notification group - event_rule.action_object.notify( - object_type=object_type, - object_id=event_data['id'], - object_repr=event_data.get('display'), - event_type=event['event_type'], ) + continue - else: - raise ValueError(_("Unknown action type for an event rule: {action_type}").format( - action_type=event_rule.action_type - )) + try: + action.enqueue( + event_rule=event_rule, + event_context=event, + action_object=event_rule.action_object, + action_data=event_data, + ) + except Exception: + # Isolate third-party bugs; a core action's own bugs should propagate instead. + if not action.is_plugin_provided: + raise + logger.exception( + _('Error processing event rule "{rule}" (action: {action_type})').format( + rule=event_rule, action_type=event_rule.action_type, + ) + ) def process_event_queue(events): diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 8bb91ed0e..09bed6be1 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -5,6 +5,7 @@ from django.utils.translation import gettext as _ from core.models import DataSource, ObjectType from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup +from netbox.event_rules import get_event_rule_action_choices, get_event_rule_action_slugs from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet, PrimaryModelFilterSet from tenancy.models import Tenant, TenantGroup from users.filterset_mixins import OwnerFilterMixin @@ -82,7 +83,7 @@ class WebhookFilterSet(OwnerFilterMixin, NetBoxModelFilterSet): model = Webhook fields = ( 'id', 'name', 'payload_url', 'http_method', 'http_content_type', 'secret', 'ssl_verification', - 'ca_file_path', 'description', + 'ca_file_path', 'timeout', 'description', ) def search(self, queryset, name, value): @@ -112,9 +113,13 @@ class EventRuleFilterSet(OwnerFilterMixin, NetBoxModelFilterSet): method='filter_event_type' ) action_type = django_filters.MultipleChoiceFilter( - choices=EventRuleActionChoices, + choices=get_event_rule_action_choices, distinct=False, ) + action_is_available = django_filters.BooleanFilter( + method='filter_action_is_available', + label=_('Action available'), + ) action_object_type = MultiValueContentTypeFilter() action_object_id = MultiValueNumberFilter() @@ -136,6 +141,12 @@ class EventRuleFilterSet(OwnerFilterMixin, NetBoxModelFilterSet): def filter_event_type(self, queryset, name, value): return queryset.filter(event_types__overlap=value) + def filter_action_is_available(self, queryset, name, value): + registered_slugs = get_event_rule_action_slugs() + if value: + return queryset.filter(action_type__in=registered_slugs) + return queryset.exclude(action_type__in=registered_slugs) + @register_filterset class CustomFieldFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet): @@ -175,8 +186,8 @@ class CustomFieldFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet): model = CustomField fields = ( 'id', 'name', 'label', 'group_name', 'required', 'unique', 'search_weight', 'filter_logic', 'ui_visible', - 'ui_editable', 'weight', 'is_cloneable', 'description', 'validation_minimum', 'validation_maximum', - 'validation_regex', + 'ui_editable', 'weight', 'is_cloneable', 'nulls_first', 'description', 'validation_minimum', + 'validation_maximum', 'validation_regex', 'status', ) def search(self, queryset, name, value): diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index 348218f1e..a376e794a 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -7,7 +7,7 @@ from netbox.events import get_event_type_choices from netbox.forms import NetBoxModelBulkEditForm, PrimaryModelBulkEditForm from netbox.forms.mixins import ChangelogMessageMixin, OwnerMixin from utilities.forms import BulkEditForm, add_blank_choice -from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, JSONField +from utilities.forms.fields import ChoiceField, ColorField, CommentField, DynamicModelChoiceField, JSONField from utilities.forms.rendering import FieldSet from utilities.forms.widgets import BulkEditNullBooleanSelect @@ -61,12 +61,12 @@ class CustomFieldBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm): queryset=CustomFieldChoiceSet.objects.all(), required=False ) - ui_visible = forms.ChoiceField( + ui_visible = ChoiceField( label=_("UI visible"), choices=add_blank_choice(CustomFieldUIVisibleChoices), required=False ) - ui_editable = forms.ChoiceField( + ui_editable = ChoiceField( label=_("UI editable"), choices=add_blank_choice(CustomFieldUIEditableChoices), required=False @@ -76,6 +76,11 @@ class CustomFieldBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm): required=False, widget=BulkEditNullBooleanSelect() ) + nulls_first = forms.NullBooleanField( + label=_('Nulls first'), + required=False, + widget=BulkEditNullBooleanSelect() + ) validation_minimum = forms.DecimalField( label=_('Minimum value'), required=False, @@ -96,7 +101,7 @@ class CustomFieldBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm): fieldsets = ( FieldSet('group_name', 'description', 'weight', 'required', 'unique', 'choice_set', name=_('Attributes')), - FieldSet('ui_visible', 'ui_editable', 'is_cloneable', name=_('Behavior')), + FieldSet('ui_visible', 'ui_editable', 'is_cloneable', 'nulls_first', name=_('Behavior')), FieldSet( 'validation_minimum', 'validation_maximum', 'validation_regex', 'validation_schema', name=_('Validation') @@ -110,7 +115,7 @@ class CustomFieldChoiceSetBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEd queryset=CustomFieldChoiceSet.objects.all(), widget=forms.MultipleHiddenInput ) - base_choices = forms.ChoiceField( + base_choices = ChoiceField( choices=add_blank_choice(CustomFieldChoiceSetBaseChoices), required=False ) @@ -144,7 +149,7 @@ class CustomLinkBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm): label=_('Weight'), required=False ) - button_class = forms.ChoiceField( + button_class = ChoiceField( label=_('Button class'), choices=add_blank_choice(CustomLinkButtonClassChoices), required=False @@ -252,7 +257,7 @@ class WebhookBulkEditForm(OwnerMixin, NetBoxModelBulkEditForm): max_length=200, required=False ) - http_method = forms.ChoiceField( + http_method = ChoiceField( choices=add_blank_choice(WebhookHttpMethodChoices), required=False, label=_('HTTP method') @@ -274,8 +279,14 @@ class WebhookBulkEditForm(OwnerMixin, NetBoxModelBulkEditForm): required=False, label=_('CA file path') ) + timeout = forms.IntegerField( + required=False, + min_value=1, + max_value=3600, + label=_('Timeout') + ) - nullable_fields = ('secret', 'ca_file_path') + nullable_fields = ('secret', 'ca_file_path', 'timeout') class EventRuleBulkEditForm(OwnerMixin, NetBoxModelBulkEditForm): @@ -434,7 +445,7 @@ class JournalEntryBulkEditForm(ChangelogMessageMixin, BulkEditForm): queryset=JournalEntry.objects.all(), widget=forms.MultipleHiddenInput ) - kind = forms.ChoiceField( + kind = ChoiceField( label=_('Kind'), choices=add_blank_choice(JournalEntryKindChoices), required=False diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index d5a047961..81ea8e6d2 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -2,12 +2,13 @@ import re from django import forms from django.contrib.postgres.forms import SimpleArrayField -from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import NON_FIELD_ERRORS, ObjectDoesNotExist, ValidationError from django.utils.translation import gettext_lazy as _ from core.models import DataFile, DataSource, ObjectType from extras.choices import * from extras.models import * +from netbox.event_rules import get_event_rule_action from netbox.events import get_event_type_choices from netbox.forms import NetBoxModelImportForm, OwnerCSVMixin, PrimaryModelImportForm from users.models import Group, User @@ -81,7 +82,7 @@ class CustomFieldImportForm(OwnerCSVMixin, CSVModelForm): 'name', 'label', 'group_name', 'type', 'object_types', 'related_object_type', 'required', 'unique', 'description', 'search_weight', 'filter_logic', 'default', 'choice_set', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'validation_schema', 'ui_visible', 'ui_editable', - 'is_cloneable', 'owner', 'comments', + 'is_cloneable', 'nulls_first', 'owner', 'comments', ) @@ -254,7 +255,7 @@ class WebhookImportForm(OwnerCSVMixin, NetBoxModelImportForm): model = Webhook fields = ( 'name', 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', - 'secret', 'ssl_verification', 'ca_file_path', 'description', 'owner', 'tags' + 'secret', 'ssl_verification', 'ca_file_path', 'timeout', 'description', 'owner', 'tags' ) @@ -271,8 +272,11 @@ class EventRuleImportForm(OwnerCSVMixin, NetBoxModelImportForm): ) action_object = forms.CharField( label=_('Action object'), - required=True, - help_text=_('Webhook name or script as dotted path module.Class') + required=False, + help_text=_( + 'The target object for the action, if it requires one. The expected format depends on the action type ' + '(e.g. a webhook or notification group name, or a script as dotted path module.Class).' + ) ) class Meta: @@ -287,24 +291,58 @@ class EventRuleImportForm(OwnerCSVMixin, NetBoxModelImportForm): action_object = self.cleaned_data.get('action_object') action_type = self.cleaned_data.get('action_type') - if action_object and action_type: - # Webhook - if action_type == EventRuleActionChoices.WEBHOOK: - try: - webhook = Webhook.objects.get(name=action_object) - except Webhook.DoesNotExist: - raise forms.ValidationError(_("Webhook {name} not found").format(name=action_object)) - self.instance.action_object = webhook - # Script - elif action_type == EventRuleActionChoices.SCRIPT: - from extras.scripts import get_module_and_script - module_name, script_name = action_object.split('.', 1) - try: - script = get_module_and_script(module_name, script_name)[1] - except ObjectDoesNotExist: - raise forms.ValidationError(_("Script {name} not found").format(name=action_object)) - self.instance.action_object = script - self.instance.action_object_type = ObjectType.objects.get_for_model(script, for_concrete_model=False) + if not action_type: + return + + action = get_event_rule_action(action_type) + if action is None: + raise forms.ValidationError({ + 'action_type': _('"{action_type}" is not a registered action type.').format(action_type=action_type) + }) + + if not action_object: + if action.object_required: + raise forms.ValidationError({ + 'action_object': _("This action type requires a target object."), + }) + # Clear any action_object this instance previously had (relevant for a CSV row that + # updates an existing rule, matched by id, to a now-object-less action_type). + self.instance.action_object_type = None + self.instance.action_object_id = None + return + + if action.object_model is None: + raise forms.ValidationError({ + 'action_object': _("This action type does not operate against a target object."), + }) + + try: + obj = action.resolve_import_object(action_object) + except ObjectDoesNotExist: + raise forms.ValidationError({ + 'action_object': _("{name} not found").format(name=action_object) + }) + if obj is None: + raise forms.ValidationError({ + 'action_object': _("This action type does not support bulk import.") + }) + + # Assign the GFK itself (not just action_object_type/id) so EventRule.clean()'s later + # access to self.action_object hits the descriptor cache instead of a fresh SELECT -- + # for a non-proxy object_model, where the concrete and non-concrete content types match. + self.instance.action_object = obj + self.instance.action_object_type = ObjectType.objects.get_for_model(obj, for_concrete_model=False) + + def _update_errors(self, errors): + # Remap errors keyed by fields this form doesn't expose (e.g. action_object_id) to + # NON_FIELD_ERRORS; otherwise Django's add_error() raises ValueError instead of failing validation normally. + if hasattr(errors, 'error_dict'): + remapped = {} + for field, messages in errors.error_dict.items(): + key = field if field == NON_FIELD_ERRORS or field in self.fields else NON_FIELD_ERRORS + remapped.setdefault(key, []).extend(messages) + errors = ValidationError(remapped) + super()._update_errors(errors) class TagImportForm(OwnerCSVMixin, CSVModelForm): diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 686bb1eb9..ec6307c12 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -5,6 +5,7 @@ from core.models import DataFile, DataSource, ObjectType from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * from extras.models import * +from netbox.event_rules import get_event_rule_action_choices from netbox.events import get_event_type_choices from netbox.forms import NetBoxModelFilterSetForm, PrimaryModelFilterSetForm from netbox.forms.mixins import OwnerFilterMixin, SavedFiltersMixin @@ -45,9 +46,11 @@ class CustomFieldFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm): model = CustomField fieldsets = ( FieldSet('q', 'filter_id'), - FieldSet('object_type_id', 'type', 'group_name', 'weight', 'required', 'unique', name=_('Attributes')), + FieldSet( + 'object_type_id', 'type', 'group_name', 'weight', 'required', 'unique', 'status', name=_('Attributes') + ), FieldSet('choice_set_id', 'related_object_type_id', name=_('Type Options')), - FieldSet('ui_visible', 'ui_editable', 'is_cloneable', name=_('Behavior')), + FieldSet('ui_visible', 'ui_editable', 'is_cloneable', 'nulls_first', name=_('Behavior')), FieldSet('validation_minimum', 'validation_maximum', 'validation_regex', name=_('Validation')), FieldSet('owner_group_id', 'owner_id', name=_('Ownership')), ) @@ -66,6 +69,11 @@ class CustomFieldFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm): required=False, label=_('Field type') ) + status = forms.ChoiceField( + choices=add_blank_choice(CustomFieldStatusChoices), + required=False, + label=_('Status') + ) group_name = forms.CharField( label=_('Group name'), required=False @@ -110,6 +118,13 @@ class CustomFieldFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + nulls_first = forms.NullBooleanField( + label=_('Nulls first'), + required=False, + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) validation_minimum = forms.DecimalField( label=_('Minimum value'), required=False @@ -308,7 +323,9 @@ class WebhookFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm): model = Webhook fieldsets = ( FieldSet('q', 'filter_id', 'tag'), - FieldSet('payload_url', 'http_method', 'http_content_type', name=_('Attributes')), + FieldSet( + 'payload_url', 'http_method', 'http_content_type', 'timeout__gte', 'timeout__lte', name=_('Attributes') + ), FieldSet('owner_group_id', 'owner_id', name=_('Ownership')), ) http_content_type = forms.CharField( @@ -324,6 +341,16 @@ class WebhookFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm): required=False, label=_('HTTP method') ) + timeout__gte = forms.IntegerField( + required=False, + min_value=1, + label=_('Minimum timeout (seconds)') + ) + timeout__lte = forms.IntegerField( + required=False, + min_value=1, + label=_('Maximum timeout (seconds)') + ) tag = TagFilterField(model) @@ -331,7 +358,9 @@ class EventRuleFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm): model = EventRule fieldsets = ( FieldSet('q', 'filter_id', 'tag'), - FieldSet('object_type_id', 'event_type', 'action_type', 'enabled', name=_('Attributes')), + FieldSet( + 'object_type_id', 'event_type', 'action_type', 'action_is_available', 'enabled', name=_('Attributes') + ), FieldSet('owner_group_id', 'owner_id', name=_('Ownership')), ) object_type_id = ContentTypeMultipleChoiceField( @@ -345,10 +374,19 @@ class EventRuleFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm): label=_('Event type') ) action_type = forms.ChoiceField( - choices=add_blank_choice(EventRuleActionChoices), + # Wrapped in a callable so the registry is read on each access, rather than frozen at the + # time this module is first imported (see EventRule.action_type). + choices=lambda: add_blank_choice(get_event_rule_action_choices()), required=False, label=_('Action type') ) + action_is_available = forms.NullBooleanField( + label=_('Action available'), + required=False, + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) enabled = forms.NullBooleanField( label=_('Enabled'), required=False, diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index ba995c701..8f02f88e1 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -12,20 +12,24 @@ from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site from extras.choices import * from extras.constants import IMAGE_ATTACHMENT_IMAGE_FORMATS from extras.models import * +from netbox.event_rules import get_event_rule_action, get_event_rule_action_choices from netbox.events import get_event_type_choices from netbox.forms import NetBoxModelForm, PrimaryModelForm from netbox.forms.mixins import ChangelogMessageMixin, OwnerMixin from tenancy.models import Tenant, TenantGroup from users.models import Group, User -from utilities.forms import get_field_value +from utilities.forms import add_blank_choice, get_field_value from utilities.forms.fields import ( + ChoiceField, CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, + MultipleChoiceField, SlugField, + TypedChoiceField, ) from utilities.forms.rendering import FieldSet, ObjectAttribute from utilities.forms.widgets import ChoicesWidget, HTMXSelect @@ -54,6 +58,33 @@ __all__ = ( class CustomFieldForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm): + type = ChoiceField( + label=_('Type'), + choices=CustomFieldTypeChoices, + initial=CustomFieldTypeChoices.TYPE_TEXT, + help_text=_( + 'The type of data stored in this field. For object/multi-object fields, select the related object ' + 'type below.' + ), + ) + filter_logic = ChoiceField( + label=_('Filter logic'), + choices=CustomFieldFilterLogicChoices, + initial=CustomFieldFilterLogicChoices.FILTER_LOOSE, + help_text=_('Loose matches any instance of a given string; exact matches the entire field.'), + ) + ui_visible = ChoiceField( + label=_('UI visible'), + choices=CustomFieldUIVisibleChoices, + initial=CustomFieldUIVisibleChoices.ALWAYS, + help_text=_('Specifies whether the custom field is displayed in the UI'), + ) + ui_editable = ChoiceField( + label=_('UI editable'), + choices=CustomFieldUIEditableChoices, + initial=CustomFieldUIEditableChoices.YES, + help_text=_('Specifies whether the custom field value can be edited in the UI'), + ) object_types = ContentTypeMultipleChoiceField( label=_('Object types'), queryset=ObjectType.objects.with_feature('custom_fields'), @@ -89,7 +120,8 @@ class CustomFieldForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm): name=_('Custom Field') ), FieldSet( - 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable', name=_('Behavior') + 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable', 'nulls_first', + name=_('Behavior') ), ) @@ -97,17 +129,14 @@ class CustomFieldForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm): model = CustomField fields = '__all__' help_texts = { - '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): super().__init__(*args, **kwargs) - # Mimic HTMXSelect() + # Mimic HTMXSelect() — no hx_target_id because changing type adds/removes + # Validation, Related Object, and Choices fieldsets dynamically. self.fields['type'].widget.attrs.update({ 'hx-get': '.', 'hx-include': '#form_fields', @@ -188,6 +217,12 @@ class CustomFieldForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm): class CustomFieldChoiceSetForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm): + base_choices = TypedChoiceField( + label=_('Base choices'), + choices=add_blank_choice(CustomFieldChoiceSetBaseChoices), + required=False, + help_text=_('Base set of predefined choices (optional)'), + ) # TODO: The extra_choices field definition diverge from the CustomFieldChoiceSet model extra_choices = forms.CharField( widget=ChoicesWidget(), @@ -302,6 +337,12 @@ class CustomFieldChoiceSetForm(ChangelogMessageMixin, OwnerMixin, forms.ModelFor class CustomLinkForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm): + button_class = ChoiceField( + label=_('Button class'), + choices=CustomLinkButtonClassChoices, + initial=CustomLinkButtonClassChoices.DEFAULT, + help_text=_('The class of the first link in a group will be used for the dropdown button'), + ) object_types = ContentTypeMultipleChoiceField( label=_('Object types'), queryset=ObjectType.objects.with_feature('custom_links') @@ -530,12 +571,17 @@ class SubscriptionForm(forms.ModelForm): class WebhookForm(OwnerMixin, NetBoxModelForm): + http_method = ChoiceField( + label=_('HTTP method'), + choices=WebhookHttpMethodChoices, + initial=WebhookHttpMethodChoices.METHOD_POST, + ) fieldsets = ( FieldSet('name', 'description', 'tags', name=_('Webhook')), FieldSet( 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', - name=_('HTTP Request') + 'timeout', name=_('HTTP Request') ), FieldSet('ssl_verification', 'ca_file_path', name=_('SSL')), ) @@ -550,15 +596,21 @@ class WebhookForm(OwnerMixin, NetBoxModelForm): class EventRuleForm(OwnerMixin, NetBoxModelForm): + action_type = ChoiceField( + label=_('Action type'), + choices=get_event_rule_action_choices, + initial=EventRuleActionChoices.WEBHOOK, + widget=HTMXSelect(hx_target_id='event-rule-action'), + ) object_types = ContentTypeMultipleChoiceField( label=_('Object types'), queryset=ObjectType.objects.with_feature('event_rules'), ) - event_types = forms.MultipleChoiceField( + event_types = MultipleChoiceField( choices=get_event_type_choices(), label=_('Event types') ) - action_choice = forms.ChoiceField( + action_choice = ChoiceField( label=_('Action choice'), choices=[] ) @@ -575,7 +627,7 @@ class EventRuleForm(OwnerMixin, NetBoxModelForm): fieldsets = ( FieldSet('name', 'description', 'object_types', 'enabled', 'tags', name=_('Event Rule')), FieldSet('event_types', 'conditions', name=_('Triggers')), - FieldSet('action_type', 'action_choice', 'action_data', name=_('Action')), + FieldSet('action_type', 'action_choice', 'action_data', name=_('Action'), html_id='event-rule-action'), ) class Meta: @@ -585,46 +637,31 @@ class EventRuleForm(OwnerMixin, NetBoxModelForm): 'action_object_type', 'action_object_id', 'action_data', 'owner', 'comments', 'tags' ) widgets = { - 'conditions': forms.Textarea(attrs={'class': 'font-monospace'}), - 'action_type': HTMXSelect(), 'action_object_type': forms.HiddenInput, 'action_object_id': forms.HiddenInput, } - def init_script_choice(self): - initial = None - if self.instance.action_type == EventRuleActionChoices.SCRIPT: - script_id = get_field_value(self, 'action_object_id') - initial = Script.objects.get(pk=script_id) if script_id else None - self.fields['action_choice'] = DynamicModelChoiceField( - label=_('Script'), - queryset=Script.objects.all(), - required=True, - initial=initial - ) + def init_action_choice(self): + action_type = get_field_value(self, 'action_type') + action = get_event_rule_action(action_type) - def init_webhook_choice(self): - initial = None - if self.instance.action_type == EventRuleActionChoices.WEBHOOK: - webhook_id = get_field_value(self, 'action_object_id') - initial = Webhook.objects.get(pk=webhook_id) if webhook_id else None - self.fields['action_choice'] = DynamicModelChoiceField( - label=_('Webhook'), - queryset=Webhook.objects.all(), - required=True, - initial=initial - ) + if action is None or action.object_model is None: + # Either an unregistered action_type (e.g. the providing plugin is not installed), or + # an action that doesn't operate on a target object at all -- no object picker needed. + self.fields.pop('action_choice', None) + return - def init_notificationgroup_choice(self): initial = None - if self.instance.action_type == EventRuleActionChoices.NOTIFICATION: - notificationgroup_id = get_field_value(self, 'action_object_id') - initial = NotificationGroup.objects.get(pk=notificationgroup_id) if notificationgroup_id else None + if self.instance.action_type == action_type: + object_id = get_field_value(self, 'action_object_id') + if object_id: + initial = action.get_object_queryset().filter(pk=object_id).first() + self.fields['action_choice'] = DynamicModelChoiceField( - label=_('Notification group'), - queryset=NotificationGroup.objects.all(), - required=True, - initial=initial + label=action.get_object_label(), + queryset=action.get_object_queryset(), + required=action.object_required, + initial=initial, ) def __init__(self, *args, **kwargs): @@ -632,35 +669,24 @@ class EventRuleForm(OwnerMixin, NetBoxModelForm): self.fields['action_object_type'].required = False self.fields['action_object_id'].required = False - # Determine the action type - action_type = get_field_value(self, 'action_type') - - if action_type == EventRuleActionChoices.WEBHOOK: - self.init_webhook_choice() - elif action_type == EventRuleActionChoices.SCRIPT: - self.init_script_choice() - elif action_type == EventRuleActionChoices.NOTIFICATION: - self.init_notificationgroup_choice() + self.init_action_choice() def clean(self): super().clean() + action = get_event_rule_action(self.cleaned_data.get('action_type')) action_choice = self.cleaned_data.get('action_choice') - # Webhook - if self.cleaned_data.get('action_type') == EventRuleActionChoices.WEBHOOK: - self.cleaned_data['action_object_type'] = ObjectType.objects.get_for_model(action_choice) - self.cleaned_data['action_object_id'] = action_choice.id - # Script - elif self.cleaned_data.get('action_type') == EventRuleActionChoices.SCRIPT: + + if action and action.object_model and action_choice: self.cleaned_data['action_object_type'] = ObjectType.objects.get_for_model( - Script, - for_concrete_model=False + action_choice, for_concrete_model=False ) - self.cleaned_data['action_object_id'] = action_choice.id - # Notification - elif self.cleaned_data.get('action_type') == EventRuleActionChoices.NOTIFICATION: - self.cleaned_data['action_object_type'] = ObjectType.objects.get_for_model(action_choice) - self.cleaned_data['action_object_id'] = action_choice.id + self.cleaned_data['action_object_id'] = action_choice.pk + elif action: + # A no-object action, or an optional object left unselected: store no action_object + self.cleaned_data['action_object_type'] = None + self.cleaned_data['action_object_id'] = None + # An unregistered action_type leaves the stored action_object untouched return self.cleaned_data @@ -892,7 +918,7 @@ class ImageAttachmentForm(forms.ModelForm): class JournalEntryForm(NetBoxModelForm): - kind = forms.ChoiceField( + kind = ChoiceField( label=_('Kind'), choices=JournalEntryKindChoices ) diff --git a/netbox/extras/graphql/enums.py b/netbox/extras/graphql/enums.py index 1f95fa7be..95e521dcd 100644 --- a/netbox/extras/graphql/enums.py +++ b/netbox/extras/graphql/enums.py @@ -1,11 +1,16 @@ +import enum + import strawberry from extras.choices import * +from netbox.event_rules import get_event_rule_action_choices +from utilities.string import enum_key __all__ = ( 'CustomFieldChoiceColorEnum', 'CustomFieldChoiceSetBaseEnum', 'CustomFieldFilterLogicEnum', + 'CustomFieldStatusEnum', 'CustomFieldTypeEnum', 'CustomFieldUIEditableEnum', 'CustomFieldUIVisibleEnum', @@ -19,10 +24,15 @@ __all__ = ( CustomFieldChoiceColorEnum = strawberry.enum(CustomFieldChoiceColorChoices.as_enum()) CustomFieldChoiceSetBaseEnum = strawberry.enum(CustomFieldChoiceSetBaseChoices.as_enum()) CustomFieldFilterLogicEnum = strawberry.enum(CustomFieldFilterLogicChoices.as_enum(prefix='filter')) +CustomFieldStatusEnum = strawberry.enum(CustomFieldStatusChoices.as_enum(prefix='status')) CustomFieldTypeEnum = strawberry.enum(CustomFieldTypeChoices.as_enum(prefix='type')) CustomFieldUIEditableEnum = strawberry.enum(CustomFieldUIEditableChoices.as_enum()) CustomFieldUIVisibleEnum = strawberry.enum(CustomFieldUIVisibleChoices.as_enum()) CustomLinkButtonClassEnum = strawberry.enum(CustomLinkButtonClassChoices.as_enum()) -EventRuleActionEnum = strawberry.enum(EventRuleActionChoices.as_enum()) +# Built from the event_rule_actions registry, which is fully populated by the time the schema is +# assembled. Fixed for the process's lifetime, as any Strawberry enum is. +EventRuleActionEnum = strawberry.enum(enum.Enum('EventRuleActionEnum', { + enum_key(choice.value): choice.value for choice in get_event_rule_action_choices() +})) JournalEntryKindEnum = strawberry.enum(JournalEntryKindChoices.as_enum(prefix='kind')) WebhookHttpMethodEnum = strawberry.enum(WebhookHttpMethodChoices.as_enum()) diff --git a/netbox/extras/graphql/filters.py b/netbox/extras/graphql/filters.py index 271b209e2..9b71a202e 100644 --- a/netbox/extras/graphql/filters.py +++ b/netbox/extras/graphql/filters.py @@ -9,7 +9,7 @@ from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLook from extras import models from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin from netbox.graphql.filter_mixins import SyncedDataFilterMixin -from netbox.graphql.filters import BaseModelFilter, ChangeLoggedModelFilter, PrimaryModelFilter +from netbox.graphql.filters import BaseModelFilter, ChangeLoggedModelFilter, PrimaryModelFilter, register_filter if TYPE_CHECKING: from core.graphql.filters import ContentTypeFilter @@ -59,7 +59,7 @@ __all__ = ( ) -@strawberry_django.filter_type(models.ConfigContext, lookups=True) +@register_filter(models.ConfigContext, lookups=True) class ConfigContextFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( @@ -116,14 +116,14 @@ class ConfigContextFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter): ) -@strawberry_django.filter_type(models.ConfigContextProfile, lookups=True) +@register_filter(models.ConfigContextProfile, lookups=True) class ConfigContextProfileFilter(SyncedDataFilterMixin, PrimaryModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() description: StrFilterLookup | None = strawberry_django.filter_field() tags: Annotated['TagFilter', strawberry.lazy('extras.graphql.filters')] | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.ConfigTemplate, lookups=True) +@register_filter(models.ConfigTemplate, lookups=True) class ConfigTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() description: StrFilterLookup | None = strawberry_django.filter_field() @@ -137,7 +137,7 @@ class ConfigTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter): as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.CustomField, lookups=True) +@register_filter(models.CustomField, lookups=True) class CustomFieldFilter(ChangeLoggedModelFilter): type: BaseFilterLookup[Annotated['CustomFieldTypeEnum', strawberry.lazy('extras.graphql.enums')]] | None = ( strawberry_django.filter_field() @@ -149,6 +149,9 @@ class CustomFieldFilter(ChangeLoggedModelFilter): strawberry_django.filter_field() ) name: StrFilterLookup | None = strawberry_django.filter_field() + status: BaseFilterLookup[Annotated['CustomFieldStatusEnum', strawberry.lazy('extras.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) label: StrFilterLookup | None = strawberry_django.filter_field() group_name: StrFilterLookup | None = strawberry_django.filter_field() description: StrFilterLookup | None = strawberry_django.filter_field() @@ -193,10 +196,11 @@ class CustomFieldFilter(ChangeLoggedModelFilter): strawberry_django.filter_field() ) is_cloneable: FilterLookup[bool] | None = strawberry_django.filter_field() + nulls_first: FilterLookup[bool] | None = strawberry_django.filter_field() comments: StrFilterLookup | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.CustomFieldChoiceSet, lookups=True) +@register_filter(models.CustomFieldChoiceSet, lookups=True) class CustomFieldChoiceSetFilter(ChangeLoggedModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() description: StrFilterLookup | None = strawberry_django.filter_field() @@ -238,7 +242,7 @@ class CustomFieldChoiceSetFilter(ChangeLoggedModelFilter): return queryset, params -@strawberry_django.filter_type(models.CustomLink, lookups=True) +@register_filter(models.CustomLink, lookups=True) class CustomLinkFilter(ChangeLoggedModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() enabled: FilterLookup[bool] | None = strawberry_django.filter_field() @@ -256,7 +260,7 @@ class CustomLinkFilter(ChangeLoggedModelFilter): new_window: FilterLookup[bool] | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.ExportTemplate, lookups=True) +@register_filter(models.ExportTemplate, lookups=True) class ExportTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() description: StrFilterLookup | None = strawberry_django.filter_field() @@ -270,7 +274,7 @@ class ExportTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter): as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.ImageAttachment, lookups=True) +@register_filter(models.ImageAttachment, lookups=True) class ImageAttachmentFilter(ChangeLoggedModelFilter): object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -288,7 +292,7 @@ class ImageAttachmentFilter(ChangeLoggedModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.JournalEntry, lookups=True) +@register_filter(models.JournalEntry, lookups=True) class JournalEntryFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter): assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( strawberry_django.filter_field() @@ -304,7 +308,7 @@ class JournalEntryFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedM comments: StrFilterLookup | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.Notification, lookups=True) +@register_filter(models.Notification, lookups=True) class NotificationFilter(BaseModelFilter): created: DatetimeFilterLookup | None = strawberry_django.filter_field() read: DatetimeFilterLookup | None = strawberry_django.filter_field() @@ -319,7 +323,7 @@ class NotificationFilter(BaseModelFilter): event_type: StrFilterLookup | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.NotificationGroup, lookups=True) +@register_filter(models.NotificationGroup, lookups=True) class NotificationGroupFilter(ChangeLoggedModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() description: StrFilterLookup | None = strawberry_django.filter_field() @@ -327,7 +331,7 @@ class NotificationGroupFilter(ChangeLoggedModelFilter): users: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.SavedFilter, lookups=True) +@register_filter(models.SavedFilter, lookups=True) class SavedFilterFilter(ChangeLoggedModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() slug: StrFilterLookup | None = strawberry_django.filter_field() @@ -344,7 +348,7 @@ class SavedFilterFilter(ChangeLoggedModelFilter): ) -@strawberry_django.filter_type(models.Subscription, lookups=True) +@register_filter(models.Subscription, lookups=True) class SubscriptionFilter(BaseModelFilter): created: DatetimeFilterLookup | None = strawberry_django.filter_field() user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() @@ -356,7 +360,7 @@ class SubscriptionFilter(BaseModelFilter): object_id: ID | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.TableConfig, lookups=True) +@register_filter(models.TableConfig, lookups=True) class TableConfigFilter(ChangeLoggedModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() description: StrFilterLookup | None = strawberry_django.filter_field() @@ -369,7 +373,7 @@ class TableConfigFilter(ChangeLoggedModelFilter): shared: FilterLookup[bool] | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.Tag, lookups=True) +@register_filter(models.Tag, lookups=True) class TagFilter(ChangeLoggedModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() slug: StrFilterLookup | None = strawberry_django.filter_field() @@ -379,7 +383,7 @@ class TagFilter(ChangeLoggedModelFilter): description: StrFilterLookup | None = strawberry_django.filter_field() -@strawberry_django.filter_type(models.Webhook, lookups=True) +@register_filter(models.Webhook, lookups=True) class WebhookFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() description: StrFilterLookup | None = strawberry_django.filter_field() @@ -395,12 +399,13 @@ class WebhookFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelF secret: StrFilterLookup | None = strawberry_django.filter_field() ssl_verification: FilterLookup[bool] | None = strawberry_django.filter_field() ca_file_path: StrFilterLookup | None = strawberry_django.filter_field() + timeout: FilterLookup[int] | None = strawberry_django.filter_field() events: Annotated['EventRuleFilter', strawberry.lazy('extras.graphql.filters')] | None = ( strawberry_django.filter_field() ) -@strawberry_django.filter_type(models.EventRule, lookups=True) +@register_filter(models.EventRule, lookups=True) class EventRuleFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter): name: StrFilterLookup | None = strawberry_django.filter_field() description: StrFilterLookup | None = strawberry_django.filter_field() diff --git a/netbox/extras/graphql/mixins.py b/netbox/extras/graphql/mixins.py index 133452e4a..f544e0686 100644 --- a/netbox/extras/graphql/mixins.py +++ b/netbox/extras/graphql/mixins.py @@ -29,15 +29,20 @@ class ConfigContextMixin: def get_queryset(cls, queryset, info: Info, **kwargs): queryset = super().get_queryset(queryset, info, **kwargs) - # If `config_context` is requested, call annotate_config_context_data() on the queryset + # When `config_context` is requested, annotate the aggregated context data — but only for + # rows whose pre-rendered cache (`_config_context_data`) is invalidated (NULL). Warm rows + # are served from the cache by get_config_context() and skip the subquery entirely + # (PostgreSQL short-circuits the CASE), so resolving config_context across a list of objects + # with cold caches no longer incurs a per-object fallback query. selected = {f.name for f in info.selected_fields[0].selections} if 'config_context' in selected and hasattr(queryset, 'annotate_config_context_data'): - return queryset.annotate_config_context_data() + return queryset.annotate_config_context_data(only_invalidated=True) return queryset - # Ensure `local_context_data` is fetched when `config_context` is requested - @strawberry_django.field(only=['local_context_data']) + # Ensure both the pre-rendered cache and `local_context_data` are fetched when `config_context` + # is requested, so the warm-cache read path requires no additional queries. + @strawberry_django.field(only=['_config_context_data', 'local_context_data']) def config_context(self) -> strawberry.scalars.JSON: return self.get_config_context() @@ -54,7 +59,7 @@ class CustomFieldsMixin: # CustomFieldManager.get_for_model() is served from the per-request cache, so this costs one # query per model rather than one per object. return { - cf.name: self.custom_field_data.get(cf.name) + cf.name: cf.resolve_selection_value(self.custom_field_data.get(cf.name)) for cf in CustomField.objects.get_for_model(self) } diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index 832b3b974..99deb43e4 100644 --- a/netbox/extras/graphql/types.py +++ b/netbox/extras/graphql/types.py @@ -1,14 +1,13 @@ from typing import TYPE_CHECKING, Annotated import strawberry -import strawberry_django from strawberry.scalars import JSON from strawberry.types import Info from core.graphql.mixins import SyncedDataMixin from extras import models from extras.graphql.mixins import CustomFieldsMixin, TagsMixin -from netbox.graphql.types import BaseObjectType, ContentTypeType, ObjectType, PrimaryObjectType +from netbox.graphql.types import BaseObjectType, ContentTypeType, ObjectType, PrimaryObjectType, register_type from users.graphql.mixins import OwnerMixin from .filters import * @@ -60,7 +59,7 @@ class SharedObjectMixin: return queryset.restrict_to_shared(info.context.request.user) -@strawberry_django.type( +@register_type( models.ConfigContextProfile, fields='__all__', filters=ConfigContextProfileFilter, @@ -70,7 +69,7 @@ class ConfigContextProfileType(SyncedDataMixin, PrimaryObjectType): pass -@strawberry_django.type( +@register_type( models.ConfigContext, fields='__all__', filters=ConfigContextFilter, @@ -93,7 +92,7 @@ class ConfigContextType(SyncedDataMixin, OwnerMixin, ObjectType): site_groups: list[Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.ConfigTemplate, fields='__all__', filters=ConfigTemplateFilter, @@ -106,7 +105,7 @@ class ConfigTemplateType(SyncedDataMixin, OwnerMixin, TagsMixin, ObjectType): device_roles: list[Annotated["DeviceRoleType", strawberry.lazy('dcim.graphql.types')]] -@strawberry_django.type( +@register_type( models.CustomField, fields='__all__', filters=CustomFieldFilter, @@ -117,7 +116,7 @@ class CustomFieldType(OwnerMixin, ObjectType): choice_set: Annotated["CustomFieldChoiceSetType", strawberry.lazy('extras.graphql.types')] | None -@strawberry_django.type( +@register_type( models.CustomFieldChoiceSet, exclude=['extra_choices', 'choice_colors'], filters=CustomFieldChoiceSetFilter, @@ -130,7 +129,7 @@ class CustomFieldChoiceSetType(OwnerMixin, ObjectType): choice_colors: JSON -@strawberry_django.type( +@register_type( models.CustomLink, fields='__all__', filters=CustomLinkFilter, @@ -140,7 +139,7 @@ class CustomLinkType(OwnerMixin, ObjectType): pass -@strawberry_django.type( +@register_type( models.ExportTemplate, fields='__all__', filters=ExportTemplateFilter, @@ -150,7 +149,7 @@ class ExportTemplateType(SyncedDataMixin, OwnerMixin, ObjectType): pass -@strawberry_django.type( +@register_type( models.ImageAttachment, fields='__all__', filters=ImageAttachmentFilter, @@ -160,7 +159,7 @@ class ImageAttachmentType(BaseObjectType): object_type: Annotated["ContentTypeType", strawberry.lazy('netbox.graphql.types')] | None -@strawberry_django.type( +@register_type( models.JournalEntry, fields='__all__', filters=JournalEntryFilter, @@ -171,7 +170,7 @@ class JournalEntryType(CustomFieldsMixin, TagsMixin, ObjectType): created_by: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None -@strawberry_django.type( +@register_type( models.Notification, filters=NotificationFilter, pagination=True @@ -180,7 +179,7 @@ class NotificationType(ObjectType): user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None -@strawberry_django.type( +@register_type( models.NotificationGroup, filters=NotificationGroupFilter, pagination=True @@ -190,7 +189,7 @@ class NotificationGroupType(ObjectType): groups: list[Annotated["GroupType", strawberry.lazy('users.graphql.types')]] -@strawberry_django.type( +@register_type( models.SavedFilter, exclude=['content_types',], filters=SavedFilterFilter, @@ -200,7 +199,7 @@ class SavedFilterType(SharedObjectMixin, OwnerMixin, ObjectType): user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None -@strawberry_django.type( +@register_type( models.Subscription, filters=SubscriptionFilter, pagination=True @@ -209,7 +208,7 @@ class SubscriptionType(ObjectType): user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None -@strawberry_django.type( +@register_type( models.TableConfig, fields='__all__', filters=TableConfigFilter, @@ -220,7 +219,7 @@ class TableConfigType(SharedObjectMixin, ObjectType): user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None -@strawberry_django.type( +@register_type( models.Tag, exclude=['extras_taggeditem_items', ], filters=TagFilter, @@ -232,7 +231,7 @@ class TagType(OwnerMixin, ObjectType): object_types: list[ContentTypeType] -@strawberry_django.type( +@register_type( models.Webhook, exclude=['content_types',], filters=WebhookFilter, @@ -242,7 +241,7 @@ class WebhookType(OwnerMixin, CustomFieldsMixin, TagsMixin, ObjectType): pass -@strawberry_django.type( +@register_type( models.EventRule, exclude=['content_types',], filters=EventRuleFilter, diff --git a/netbox/extras/jobs.py b/netbox/extras/jobs.py index efaa97247..e8c44e36a 100644 --- a/netbox/extras/jobs.py +++ b/netbox/extras/jobs.py @@ -2,11 +2,16 @@ import logging import traceback from contextlib import ExitStack +from django.apps import apps from django.db import DEFAULT_DB_ALIAS, router, transaction from django.utils.translation import gettext as _ +from django_pg_utils import advisory_lock from core.signals import clear_events from dcim.models import Device +from extras.choices import CustomFieldStatusChoices +from extras.constants import CUSTOMFIELD_JOB_TIMEOUT +from extras.models import CustomField from extras.models import Script as ScriptModel from extras.scripts import _UNSET from netbox.context_managers import event_tracking @@ -16,6 +21,244 @@ from utilities.exceptions import AbortScript, AbortTransaction from .utils import is_report +__all__ = ( + 'CustomFieldDataJob', + 'CustomFieldProvisioningJob', + 'CustomFieldPurgeJob', + 'RenderConfigContextJob', + 'ScriptJob', + 'provision_custom_field', + 'purge_custom_field', +) + + +# +# Config contexts +# + +RENDER_CONFIG_CONTEXT_CHUNK_SIZE = 500 + +# Safety bound on the number of re-scan passes performed by RenderConfigContextJob.run() (see the +# loop there). Each pass re-queries for NULL caches, so any finite burst of concurrent +# invalidations is drained well within this limit; the cap only guards against an object whose +# cache is being invalidated faster than it can be rendered (pathological, unbounded churn). +RENDER_CONFIG_CONTEXT_MAX_PASSES = 100 + + +class RenderConfigContextJob(JobRunner): + """ + Recompute the pre-rendered `_config_context_data` cache for a set of Devices or + VirtualMachines. Enqueued (coalesced) by the invalidation helpers in extras/cache.py whenever + an upstream change (ConfigContext, related object, or the object itself) NULLs a cache. + + This is *not* a recurring system job: the initial post-upgrade population is handled by the + `rebuild_config_context_cache` management command, and steady-state freshness is maintained by + the invalidation signals. + """ + + class Meta: + name = 'Render config context' + + def run(self, model_label=None, pks=None, **kwargs): + """ + Args: + model_label: 'dcim.device' or 'virtualization.virtualmachine'. If None, both are processed. + pks: An iterable of object PKs to refresh. If None, refresh all objects whose cache is null. + """ + labels = (model_label,) if model_label is not None else ('dcim.device', 'virtualization.virtualmachine') + pks = list(pks) if pks is not None else None + + # Re-scan until a full pass renders nothing. An invalidation that commits while this job is + # already RUNNING coalesces into this job — JobRunner.enqueue_once() treats RUNNING as an + # enqueued state — so it will NOT schedule a follow-up job. If we rendered in a single pass, + # any cache NULLed after the iterator moved past its row (or after the pass for its model + # completed) would be left populated by no one, stranding it on the on-demand read path + # indefinitely. Looping until a pass finds no renderable NULL caches guarantees those late + # invalidations are picked up before this job finishes. + total = 0 + for _pass in range(RENDER_CONFIG_CONTEXT_MAX_PASSES): + rendered = sum(self._render_for_model(label, pks=pks) for label in labels) + total += rendered + # No progress this pass means either nothing is NULL or the only NULL rows are churning + # under concurrent invalidation (each such invalidation enqueues its own follow-up), so + # there is nothing more for us to safely do. + if not rendered: + break + else: + # The loop ran every pass without ever rendering nothing, meaning caches are being + # invalidated about as fast as we can render them. This is pathological churn worth + # surfacing: each lingering invalidation enqueues its own follow-up job, so the caches + # are not stranded, but the sustained rate warrants investigation. + self.logger.warning( + f"Reached the maximum of {RENDER_CONFIG_CONTEXT_MAX_PASSES} render passes with caches " + f"still being invalidated; config context caches may be churning under sustained " + f"concurrent invalidation." + ) + + self.logger.info(f"Rendered config context for {total} object(s)") + + def _render_for_model(self, model_label, pks): + """ + Render and cache config context for every object of the given model whose cache is + currently NULL (optionally restricted to `pks`). Returns the number of objects written. + """ + Model = apps.get_model(model_label) + qs = Model.objects.filter(_config_context_data__isnull=True) + if pks is not None: + qs = qs.filter(pk__in=list(pks)) + + # Annotate so each instance's render() uses the same aggregated subquery the on-demand + # path would use, avoiding N additional queries. + qs = qs.annotate_config_context_data() + + rendered = 0 + for obj in qs.iterator(chunk_size=RENDER_CONFIG_CONTEXT_CHUNK_SIZE): + # Capture the generation we rendered against, then write the result back only if no + # invalidation has bumped it in the meantime (compare-and-set). If a fresh invalidation + # won the race, the row stays NULL with a higher generation and the follow-up job it + # enqueued will re-render it — we never persist a stale value. + generation = obj._config_context_generation + data = obj.render_config_context() + updated = Model.objects.filter( + pk=obj.pk, + _config_context_generation=generation, + ).update(_config_context_data=data) + rendered += updated + + return rendered + + +# +# Custom fields +# + + +def provision_custom_field(pk, object_type_pks): + """ + Populate a new custom field's default value across the objects of the given types, then bring + the field live. Returns True if the field was brought live. + + The backfill is committed in batches, so an interruption leaves the field provisioning with some + of its objects already updated. Running again completes it. + + Args: + pk: The primary key of the CustomField to provision + object_type_pks: The primary keys of the object types to provision. Named explicitly, as + only the caller which deferred the work knows which of the field's assignments are the + new ones. + """ + # Taken on the connection the field is written on, as CustomField.delete() takes it, so that + # the two are actually exclusive of one another. + using = router.db_for_write(CustomField) + with advisory_lock(CustomField.data_lock_key(pk), using=using): + + # Rechecked now that the lock is held: where two jobs were enqueued for the same field, + # whichever arrived first has left it in a state the other no longer matches. + custom_field = CustomField.objects.filter(pk=pk, status=CustomFieldStatusChoices.STATUS_PROVISIONING).first() + if custom_field is None: + return False + + # Restricted to the field's current assignments: a type unassigned since the job was + # enqueued must not be provisioned, its data having been removed by remove_data(). That + # method refuses an unassignment while the field is being provisioned, so this covers only + # a change made without it -- through the m2m table directly, which emits no signal. + object_types = custom_field.object_types.filter(pk__in=object_type_pks) + custom_field.populate_initial_data(object_types, commit_per_batch=True) + + # Applied via the queryset so that bringing the field live does not record a change of its + # own, and cannot trip the guard in CustomField.clean(). + activated = CustomField.objects.filter( + pk=pk, status=CustomFieldStatusChoices.STATUS_PROVISIONING + ).update(status=CustomFieldStatusChoices.STATUS_ACTIVE) + CustomField.objects.clear_cache() + + return bool(activated) + + +def purge_custom_field(pk): + """ + Remove a deleted custom field's data from all applicable objects, then remove the field itself. + Returns True if the field was purged. + + The row is dropped only once its data is gone: until then it reserves the field's name against a + new field which would otherwise inherit the orphaned values. The removal is committed in batches, + so an interruption leaves data behind for a later run to finish removing. + + Args: + pk: The primary key of the CustomField to purge + """ + # Taken on the connection the field is written on, as CustomField.delete() takes it, so that + # the two are actually exclusive of one another. + using = router.db_for_write(CustomField) + with advisory_lock(CustomField.data_lock_key(pk), using=using): + + # Rechecked now that the lock is held: where two jobs were enqueued for the same field, + # whichever arrived first has left it in a state the other no longer matches. + custom_field = CustomField.objects.filter(pk=pk, status=CustomFieldStatusChoices.STATUS_DELETING).first() + if custom_field is None: + return False + + custom_field.remove_stale_data(custom_field.object_types.all(), commit_per_batch=True) + custom_field._delete_row() + + return True + + +class CustomFieldDataJob(JobRunner): + """ + Base class for the jobs which rewrite a custom field's stored data in bulk. + + The field is passed by primary key rather than assigned to the job as its object. Job.clean() + permits only models with the jobs feature there, and granting CustomField that feature would + give it a cascading relation to its jobs -- so the purge job, whose last act is to remove the + row, would delete the record of its own execution as it ran. + """ + @classmethod + def enqueue_for(cls, custom_field, **kwargs): + """ + Enqueue this job for the given custom field, naming the field in the job's name and raising + its timeout from the default (see CUSTOMFIELD_JOB_TIMEOUT). + """ + return cls.enqueue( + name=f'{cls.name}: {custom_field}', + custom_field_pk=custom_field.pk, + job_timeout=CUSTOMFIELD_JOB_TIMEOUT, + **kwargs, + ) + + +class CustomFieldProvisioningJob(CustomFieldDataJob): + """ + Populate the default value of a newly created custom field. + """ + class Meta: + name = 'Custom Field Provisioning' + + def run(self, custom_field_pk, *args, object_type_pks, **kwargs): + if provision_custom_field(custom_field_pk, object_type_pks): + self.logger.info("Custom field provisioned") + else: + self.logger.info("Custom field is no longer awaiting provisioning; skipping") + + +class CustomFieldPurgeJob(CustomFieldDataJob): + """ + Purge the stored data of a deleted custom field, then delete the field. + """ + class Meta: + name = 'Custom Field Purge' + + def run(self, custom_field_pk, *args, **kwargs): + if purge_custom_field(custom_field_pk): + self.logger.info("Custom field data purged") + else: + self.logger.info("Custom field is no longer awaiting deletion; skipping") + + +# +# Scripts +# + class ScriptJob(JobRunner): """ diff --git a/netbox/extras/management/commands/housekeeping.py b/netbox/extras/management/commands/housekeeping.py deleted file mode 100644 index e68142ac3..000000000 --- a/netbox/extras/management/commands/housekeeping.py +++ /dev/null @@ -1,180 +0,0 @@ -import warnings -from datetime import timedelta -from importlib import import_module - -import requests -from django.conf import settings -from django.core.cache import cache -from django.core.management.base import BaseCommand -from django.db.models import Exists, OuterRef, Subquery -from django.utils import timezone -from packaging import version - -from core.choices import ObjectChangeActionChoices -from core.models import Job, ObjectChange -from netbox.config import Config -from utilities.proxy import resolve_proxies - - -class Command(BaseCommand): - help = "Perform nightly housekeeping tasks [DEPRECATED]" - - def handle(self, *args, **options): - warnings.warn( - "\n\nDEPRECATION WARNING\n" - "Running this command is no longer necessary: All housekeeping tasks\n" - "are addressed automatically via NetBox's built-in job scheduler. It\n" - "will be removed in a future release.\n", - category=FutureWarning, - ) - - config = Config() - - # Clear expired authentication sessions (essentially replicating the `clearsessions` command) - if options['verbosity']: - self.stdout.write("[*] Clearing expired authentication sessions") - if options['verbosity'] >= 2: - self.stdout.write(f"\tConfigured session engine: {settings.SESSION_ENGINE}") - engine = import_module(settings.SESSION_ENGINE) - try: - engine.SessionStore.clear_expired() - if options['verbosity']: - self.stdout.write("\tSessions cleared.", self.style.SUCCESS) - except NotImplementedError: - if options['verbosity']: - self.stdout.write( - f"\tThe configured session engine ({settings.SESSION_ENGINE}) does not support " - f"clearing sessions; skipping." - ) - - # Delete expired ObjectChanges - if options['verbosity']: - self.stdout.write('[*] Checking for expired changelog records') - if config.CHANGELOG_RETENTION: - cutoff = timezone.now() - timedelta(days=config.CHANGELOG_RETENTION) - if options['verbosity'] >= 2: - self.stdout.write(f'\tRetention period: {config.CHANGELOG_RETENTION} days') - self.stdout.write(f'\tCut-off time: {cutoff}') - - expired_qs = ObjectChange.objects.filter(time__lt=cutoff) - - # When enabled, retain each object's original create and most recent update record while pruning expired - # changelog entries. This applies only to objects without a delete record. - if config.CHANGELOG_RETAIN_CREATE_LAST_UPDATE: - if options['verbosity'] >= 2: - self.stdout.write('\tRetaining create & last update records for non-deleted objects') - - deleted_exists = ObjectChange.objects.filter( - action=ObjectChangeActionChoices.ACTION_DELETE, - changed_object_type_id=OuterRef('changed_object_type_id'), - changed_object_id=OuterRef('changed_object_id'), - ) - - # Keep create records only where no delete exists for that object - create_pks_to_keep = ( - ObjectChange.objects.filter(action=ObjectChangeActionChoices.ACTION_CREATE) - .annotate(has_delete=Exists(deleted_exists)) - .filter(has_delete=False) - .values('pk') - ) - - # Keep the most recent update per object only where no delete exists for the object - latest_update_pks_to_keep = ( - ObjectChange.objects.filter(action=ObjectChangeActionChoices.ACTION_UPDATE) - .annotate(has_delete=Exists(deleted_exists)) - .filter(has_delete=False) - .order_by('changed_object_type_id', 'changed_object_id', '-time', '-pk') - .distinct('changed_object_type_id', 'changed_object_id') - .values('pk') - ) - - expired_qs = expired_qs.exclude(pk__in=Subquery(create_pks_to_keep)) - expired_qs = expired_qs.exclude(pk__in=Subquery(latest_update_pks_to_keep)) - - expired_records = expired_qs.count() - if expired_records: - if options['verbosity']: - self.stdout.write( - f'\tDeleting {expired_records} expired records... ', self.style.WARNING, ending='' - ) - self.stdout.flush() - expired_qs.delete() - if options['verbosity']: - self.stdout.write('Done.', self.style.SUCCESS) - elif options['verbosity']: - self.stdout.write('\tNo expired records found.', self.style.SUCCESS) - elif options['verbosity']: - self.stdout.write( - f'\tSkipping: No retention period specified (CHANGELOG_RETENTION = {config.CHANGELOG_RETENTION})' - ) - - # Delete expired Jobs - if options['verbosity']: - self.stdout.write("[*] Checking for expired jobs") - if config.JOB_RETENTION: - cutoff = timezone.now() - timedelta(days=config.JOB_RETENTION) - if options['verbosity'] >= 2: - self.stdout.write(f"\tRetention period: {config.JOB_RETENTION} days") - self.stdout.write(f"\tCut-off time: {cutoff}") - expired_records = Job.objects.filter(created__lt=cutoff).count() - if expired_records: - if options['verbosity']: - self.stdout.write( - f"\tDeleting {expired_records} expired records... ", - self.style.WARNING, - ending="" - ) - self.stdout.flush() - Job.objects.filter(created__lt=cutoff).delete() - if options['verbosity']: - self.stdout.write("Done.", self.style.SUCCESS) - elif options['verbosity']: - self.stdout.write("\tNo expired records found.", self.style.SUCCESS) - elif options['verbosity']: - self.stdout.write( - f"\tSkipping: No retention period specified (JOB_RETENTION = {config.JOB_RETENTION})" - ) - - # Check for new releases (if enabled) - if options['verbosity']: - self.stdout.write("[*] Checking for latest release") - if settings.ISOLATED_DEPLOYMENT: - if options['verbosity']: - self.stdout.write("\tSkipping: ISOLATED_DEPLOYMENT is enabled") - elif settings.RELEASE_CHECK_URL: - headers = { - 'Accept': 'application/vnd.github.v3+json', - } - - try: - if options['verbosity'] >= 2: - self.stdout.write(f"\tFetching {settings.RELEASE_CHECK_URL}") - response = requests.get( - url=settings.RELEASE_CHECK_URL, - headers=headers, - proxies=resolve_proxies(url=settings.RELEASE_CHECK_URL) - ) - response.raise_for_status() - - releases = [] - for release in response.json(): - if 'tag_name' not in release or release.get('devrelease') or release.get('prerelease'): - continue - releases.append((version.parse(release['tag_name']), release.get('html_url'))) - latest_release = max(releases) - if options['verbosity'] >= 2: - self.stdout.write(f"\tFound {len(response.json())} releases; {len(releases)} usable") - if options['verbosity']: - self.stdout.write(f"\tLatest release: {latest_release[0]}", self.style.SUCCESS) - - # Cache the most recent release - cache.set('latest_release', latest_release, None) - - except requests.exceptions.RequestException as exc: - self.stdout.write(f"\tRequest error: {exc}", self.style.ERROR) - else: - if options['verbosity']: - self.stdout.write("\tSkipping: RELEASE_CHECK_URL not set") - - if options['verbosity']: - self.stdout.write("Finished.", self.style.SUCCESS) diff --git a/netbox/extras/management/commands/rebuild_config_context_cache.py b/netbox/extras/management/commands/rebuild_config_context_cache.py new file mode 100644 index 000000000..6cba92311 --- /dev/null +++ b/netbox/extras/management/commands/rebuild_config_context_cache.py @@ -0,0 +1,47 @@ +from django.apps import apps +from django.core.management.base import BaseCommand + +from extras.jobs import RENDER_CONFIG_CONTEXT_CHUNK_SIZE + +MODELS = ('dcim.device', 'virtualization.virtualmachine') + + +class Command(BaseCommand): + help = "Pre-render and cache config context data for all devices and virtual machines" + + def add_arguments(self, parser): + parser.add_argument( + '--force', action='store_true', + help="Re-render every object, including those whose cache is already populated" + ) + + def handle(self, *args, **options): + for model_label in MODELS: + Model = apps.get_model(model_label) + qs = Model.objects.all() + if not options['force']: + qs = qs.filter(_config_context_data__isnull=True) + + # Annotate so each instance renders from the same aggregated subquery the on-demand path + # uses, avoiding N additional queries per object. + qs = qs.annotate_config_context_data() + + self.stdout.write(f'Rendering config context for {qs.count()} {model_label} object(s)...') + + rendered = 0 + for obj in qs.iterator(chunk_size=RENDER_CONFIG_CONTEXT_CHUNK_SIZE): + # Capture the generation we render against and write the result back only if no + # invalidation has bumped it in the meantime (compare-and-set). This mirrors + # RenderConfigContextJob and ensures that running this command on a live system + # cannot clobber a concurrent invalidation with a stale render (which would leave a + # populated-but-stale cache that the background sweep would then skip). + generation = obj._config_context_generation + data = obj.render_config_context() + rendered += Model.objects.filter( + pk=obj.pk, + _config_context_generation=generation, + ).update(_config_context_data=data) + + self.stdout.write(self.style.SUCCESS(f' Rendered {rendered} {model_label} object(s).')) + + self.stdout.write(self.style.SUCCESS('Finished.')) diff --git a/netbox/extras/management/commands/renaturalize.py b/netbox/extras/management/commands/renaturalize.py index d31b4ae02..0b3164443 100644 --- a/netbox/extras/management/commands/renaturalize.py +++ b/netbox/extras/management/commands/renaturalize.py @@ -2,6 +2,7 @@ from django.apps import apps from django.core.management.base import BaseCommand, CommandError from utilities.fields import NaturalOrderingField +from utilities.querysets import chunked_update class Command(BaseCommand): @@ -93,7 +94,7 @@ class Command(BaseCommand): self.stdout.flush() # Update each unique field value in bulk - changed = model.objects.filter(name=value).update(**{field.name: naturalized_value}) + changed = chunked_update(model.objects.filter(name=value), **{field.name: naturalized_value}) if options['verbosity'] >= 2: self.stdout.write(f" ({changed})") diff --git a/netbox/extras/managers.py b/netbox/extras/managers.py index 450af466e..6c6406755 100644 --- a/netbox/extras/managers.py +++ b/netbox/extras/managers.py @@ -11,8 +11,11 @@ __all__ = ( class NetBoxTaggableManager(_TaggableManager): """ - Extends taggit's _TaggableManager to replace the per-tag get_or_create loop in add() with a - single bulk_create() call, reducing SQL queries from O(N) to O(1) when assigning tags. + Extends taggit's _TaggableManager to: + + * Replace the per-tag get_or_create loop in add() with a single bulk_create() call, reducing + SQL queries from O(N) to O(1) when assigning tags. + * Implement set_base(), the M2M assignment entry point Django's deserializer calls. """ @require_instance_manager @@ -67,6 +70,21 @@ class NetBoxTaggableManager(_TaggableManager): using=db, ) + @require_instance_manager + def set_base(self, objs, *, clear=False, through_defaults=None, raw=False): + # Django's deserializer assigns M2M data through this method, passing primary keys; + # taggit's set() takes only Tag instances or names. Keys which match no tag are passed + # through for the database to reject, as ManyRelatedManager.set_base() does. + tag_model = self.through.tag_model() + if pks := [obj for obj in objs if not isinstance(obj, (tag_model, str))]: + db = router.db_for_write(self.through, instance=self.instance) + tags = tag_model._default_manager.using(db).in_bulk(pks) + objs = [ + obj if isinstance(obj, (tag_model, str)) else tags.get(obj) or tag_model(pk=obj) + for obj in objs + ] + return self.set(objs, clear=clear, through_defaults=through_defaults) + class NetBoxTaggableManagerField(TaggableManager): """ diff --git a/netbox/extras/migrations/0134_owner.py b/netbox/extras/migrations/0134_owner.py index 2e47cc4e2..1a01fd95b 100644 --- a/netbox/extras/migrations/0134_owner.py +++ b/netbox/extras/migrations/0134_owner.py @@ -13,77 +13,77 @@ class Migration(migrations.Migration): model_name='configcontext', name='owner', field=models.ForeignKey( - blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner' ), ), migrations.AddField( model_name='configcontextprofile', name='owner', field=models.ForeignKey( - blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner' ), ), migrations.AddField( model_name='configtemplate', name='owner', field=models.ForeignKey( - blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner' ), ), migrations.AddField( model_name='customfield', name='owner', field=models.ForeignKey( - blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner' ), ), migrations.AddField( model_name='customfieldchoiceset', name='owner', field=models.ForeignKey( - blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner' ), ), migrations.AddField( model_name='customlink', name='owner', field=models.ForeignKey( - blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner' ), ), migrations.AddField( model_name='eventrule', name='owner', field=models.ForeignKey( - blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner' ), ), migrations.AddField( model_name='exporttemplate', name='owner', field=models.ForeignKey( - blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner' ), ), migrations.AddField( model_name='savedfilter', name='owner', field=models.ForeignKey( - blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner' ), ), migrations.AddField( model_name='tag', name='owner', field=models.ForeignKey( - blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner' ), ), migrations.AddField( model_name='webhook', name='owner', field=models.ForeignKey( - blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner' ), ), ] diff --git a/netbox/extras/migrations/0141_custom_field_nulls_first.py b/netbox/extras/migrations/0141_custom_field_nulls_first.py new file mode 100644 index 000000000..d8c2ebe9d --- /dev/null +++ b/netbox/extras/migrations/0141_custom_field_nulls_first.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("extras", "0140_imageattachment_image_size"), + ] + + operations = [ + migrations.AddField( + model_name="customfield", + name="nulls_first", + field=models.BooleanField(default=True), + ), + ] diff --git a/netbox/extras/migrations/0142_webhook_timeout.py b/netbox/extras/migrations/0142_webhook_timeout.py new file mode 100644 index 000000000..4004e494f --- /dev/null +++ b/netbox/extras/migrations/0142_webhook_timeout.py @@ -0,0 +1,24 @@ +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("extras", "0141_custom_field_nulls_first"), + ] + + operations = [ + migrations.AddField( + model_name="webhook", + name="timeout", + field=models.PositiveSmallIntegerField( + blank=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(3600), + ], + ), + ), + ] diff --git a/netbox/extras/migrations/0143_event_rule_action_registry.py b/netbox/extras/migrations/0143_event_rule_action_registry.py new file mode 100644 index 000000000..f97901577 --- /dev/null +++ b/netbox/extras/migrations/0143_event_rule_action_registry.py @@ -0,0 +1,29 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0142_webhook_timeout'), + ] + + operations = [ + migrations.AlterField( + model_name='eventrule', + name='action_object_type', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='eventrule_actions', + to='contenttypes.contenttype', + ), + ), + migrations.AlterField( + model_name='eventrule', + name='action_type', + field=models.CharField(default='webhook', max_length=100), + ), + ] diff --git a/netbox/extras/migrations/0144_customfield_status.py b/netbox/extras/migrations/0144_customfield_status.py new file mode 100644 index 000000000..63a4a1a05 --- /dev/null +++ b/netbox/extras/migrations/0144_customfield_status.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0143_event_rule_action_registry'), + ] + + operations = [ + migrations.AddField( + model_name='customfield', + name='status', + field=models.CharField(default='active', editable=False, max_length=50), + ), + ] diff --git a/netbox/extras/models/configs.py b/netbox/extras/models/configs.py index 875ea854b..a65859337 100644 --- a/netbox/extras/models/configs.py +++ b/netbox/extras/models/configs.py @@ -1,3 +1,4 @@ +import copy import os import re import sys @@ -7,6 +8,7 @@ import jsonschema from django.conf import settings from django.core.validators import ValidationError from django.db import models +from django.db.models import Q from django.urls import reverse from django.utils.translation import gettext_lazy as _ from jinja2.exceptions import TemplateError @@ -218,12 +220,137 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, OwnerMixin, self.data = self.data_file.get_data() sync_data.alters_data = True + def get_affected_objects(self, using=None): + """ + Return a (device_qs, vm_qs) tuple of all Devices and VirtualMachines that fall within this + ConfigContext's scope. This is the inverse of ConfigContextQuerySet.get_for_object(). + Used to determine which pre-rendered context caches must be invalidated when this + ConfigContext changes. + + `using` pins every query (both the scope lookups and the returned querysets) to the given + database alias; None defers to the router, as an unpinned query would. + """ + from dcim.models import Device + from virtualization.models import VirtualMachine + + device_q, vm_q = self._get_affected_object_filters(using=using) + return ( + Device.objects.using(using).filter(device_q), + VirtualMachine.objects.using(using).filter(vm_q), + ) + + def _get_affected_object_filters(self, using=None): + """ + Build the Q expressions matching Devices and VirtualMachines in this context's scope. + Returns (device_q, vm_q). Does NOT consider `is_active` — callers that need that should + check it separately. For invalidation purposes, we want the scope set regardless of + whether the context is currently active (toggling is_active also requires invalidation). + `using` pins the scope lookups to the given database alias. + """ + from extras.models.tags import TaggedItem + + def _nested_scope_q(m2m, object_path): + # Match objects whose `object_path` ltree column is a descendant-or-equal of any node + # selected in this nested-group m2m (regions, locations, etc.). This is the inverse of + # the forward `__path__ancestor_or_equal` match in ConfigContextQuerySet: there + # a CC's node must be an ancestor of the object's node; here the object's node must fall + # within a CC node's subtree. Returns None if the m2m is empty (no scope restriction). + paths = list(m2m.using(using).values_list('path', flat=True)) + if not paths: + return None + q = Q() + for path in paths: + q |= Q(**{f'{object_path}__descendant_or_equal': path}) + return q + + def _direct_pks(m2m): + pks = list(m2m.using(using).values_list('pk', flat=True)) + return pks or None + + # Shared filters (applicable to both Device and VirtualMachine) + shared = Q() + + region_q = _nested_scope_q(self.regions, 'site__region__path') + if region_q is not None: + shared &= region_q + + site_group_q = _nested_scope_q(self.site_groups, 'site__group__path') + if site_group_q is not None: + shared &= site_group_q + + role_q = _nested_scope_q(self.roles, 'role__path') + if role_q is not None: + shared &= role_q + + platform_q = _nested_scope_q(self.platforms, 'platform__path') + if platform_q is not None: + shared &= platform_q + + for m2m, path in ( + (self.sites, 'site'), + (self.cluster_types, 'cluster__type'), + (self.cluster_groups, 'cluster__group'), + (self.clusters, 'cluster'), + (self.tenant_groups, 'tenant__group'), + (self.tenants, 'tenant'), + ): + pks = _direct_pks(m2m) + if pks is not None: + shared &= Q(**{f'{path}__in': pks}) + + # Tag-scoped contexts: object must be tagged with at least one of the context's tags + tag_pks = _direct_pks(self.tags) + + device_q = Q(shared) + vm_q = Q(shared) + + # Device-only filters: location (nested/ltree) and device_type (direct) + location_q = _nested_scope_q(self.locations, 'location__path') + if location_q is not None: + device_q &= location_q + device_type_pks = _direct_pks(self.device_types) + if device_type_pks is not None: + device_q &= Q(device_type__in=device_type_pks) + # For VMs, locations and device_types must be empty for the context to apply + if location_q is not None or device_type_pks is not None: + vm_q &= Q(pk__in=()) + + if tag_pks is not None: + device_tagged = TaggedItem.objects.using(using).filter( + tag_id__in=tag_pks, + content_type__app_label='dcim', + content_type__model='device', + ).values_list('object_id', flat=True) + vm_tagged = TaggedItem.objects.using(using).filter( + tag_id__in=tag_pks, + content_type__app_label='virtualization', + content_type__model='virtualmachine', + ).values_list('object_id', flat=True) + device_q &= Q(pk__in=device_tagged) + vm_q &= Q(pk__in=vm_tagged) + + return device_q, vm_q + class ConfigContextModel(models.Model): """ A model which includes local configuration context data. This local data will override any inherited data from ConfigContexts. """ + # Pre-rendered config context cache. NULL means "invalidated; render on demand". Populated by + # extras.jobs.RenderConfigContextJob in the background. + _config_context_data = models.JSONField( + blank=True, + null=True, + editable=False, + ) + # Monotonic counter bumped each time the cache is invalidated. The background renderer captures + # this value before rendering and only writes the result back if it is unchanged, so a fresh + # invalidation that lands mid-render is never overwritten by a stale value (compare-and-set). + _config_context_generation = models.PositiveBigIntegerField( + default=0, + editable=False, + ) local_context_data = models.JSONField( blank=True, null=True, @@ -236,9 +363,25 @@ class ConfigContextModel(models.Model): abstract = True def get_config_context(self): + """ + Return the merged config context for this object. If a pre-rendered cache is present + (`_config_context_data`), return a copy of it. Otherwise, fall back to rendering on demand. + + The returned dict is always safe for callers to mutate (e.g. ObjectRenderConfigView merges + in additional context with .update()): the cached blob is deep-copied so mutations cannot + leak back into this instance's in-memory cache, matching the fresh-dict guarantee of the + on-demand render path. + """ + cached = getattr(self, '_config_context_data', None) + if cached is not None: + return copy.deepcopy(cached) + return self.render_config_context() + + def render_config_context(self): """ Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs. - Return the rendered configuration context for a device or VM. + Return the rendered configuration context for a device or VM. This bypasses the pre-rendered cache + (`_config_context_data`); use get_config_context() for the cached read path. """ data = {} @@ -267,6 +410,15 @@ class ConfigContextModel(models.Model): {'local_context_data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'} ) + def serialize_object(self, exclude=None): + # Exclude the pre-rendered cache and its generation counter from change-log snapshots; + # they are derived fields and would otherwise produce noisy diffs. + exclude = list(exclude or []) + for field in ('_config_context_data', '_config_context_generation'): + if field not in exclude: + exclude.append(field) + return super().serialize_object(exclude=exclude) + # # Config templates diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 710ce57d7..ec52d9010 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -1,3 +1,4 @@ +import copy import decimal import json import re @@ -8,8 +9,8 @@ import jsonschema from django import forms from django.conf import settings from django.core.validators import RegexValidator, ValidationError -from django.db import models, transaction -from django.db.models import F, Func, Value +from django.db import connections, models, router, transaction +from django.db.models import F, Func, Q, Value from django.urls import reverse from django.utils.html import escape from django.utils.safestring import mark_safe @@ -18,9 +19,9 @@ from jsonschema.exceptions import ValidationError as JSONValidationError from core.models import ObjectType from extras.choices import * -from extras.constants import CUSTOMFIELD_DATA_BATCH_SIZE from extras.data import CHOICE_SETS from extras.fields import ChoiceSetField +from netbox.constants import ADVISORY_LOCK_KEYS from netbox.context import query_cache from netbox.models import ChangeLoggedModel from netbox.models.features import CloningMixin, ExportTemplatesMixin @@ -28,6 +29,7 @@ from netbox.models.mixins import OwnerMixin from netbox.search import FieldTypes from utilities import filters from utilities.datetime import datetime_from_timestamp +from utilities.exceptions import AbortRequest from utilities.forms.fields import ( CSVChoiceField, CSVModelChoiceField, @@ -43,9 +45,9 @@ from utilities.forms.fields import ( from utilities.forms.utils import add_blank_choice from utilities.forms.widgets import APISelect, APISelectMultiple, DatePicker, DateTimePicker from utilities.jsonschema import validate_schema -from utilities.querysets import RestrictedQuerySet +from utilities.querysets import RestrictedQuerySet, chunked_update from utilities.templatetags.builtins.filters import render_markdown -from utilities.validators import validate_regex +from utilities.validators import url_scheme_is_allowed, validate_regex __all__ = ( 'CustomField', @@ -66,36 +68,77 @@ SEARCH_TYPES = { class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): use_in_migrations = True - def get_for_model(self, model): + def get_for_model(self, model, statuses=(CustomFieldStatusChoices.STATUS_ACTIVE,)): """ - Return all CustomFields assigned to the given model. + Return a list of the CustomFields assigned to the given model which hold one of the given + statuses. + + Only active fields are returned by default: a field awaiting a bulk update of its stored data + is not live, and must be invisible to every consumer of custom field data until that work + completes (see CustomFieldStatusChoices). This is the sole entry point by which custom fields + are resolved for an object, so excluding them here excludes them everywhere. + + Every assigned field is fetched and cached whichever statuses are asked for, so that callers + wanting different subsets share one query per model per request. + + Args: + model: The model whose custom fields are to be returned + statuses: The statuses to select (active only by default) """ - # Check the request cache before hitting the database. Test the cached value against None - # rather than for truthiness: a model with no custom fields caches an empty QuerySet, which - # would otherwise be treated as a miss and re-queried on every call. cache = query_cache.get() - if cache is not None: - if (custom_fields := cache['custom_fields'].get(model._meta.model)) is not None: - return custom_fields - content_type = ObjectType.objects.get_for_model(model._meta.concrete_model) - custom_fields = self.get_queryset().filter(object_types=content_type).select_related('related_object_type') + # Check the request cache before hitting the database. Test the cached value against None + # rather than for truthiness: a model with no custom fields caches an empty list, which + # would otherwise be treated as a miss and re-queried on every call. + custom_fields = cache['custom_fields'].get(model._meta.model) if cache is not None else None + if custom_fields is None: + content_type = ObjectType.objects.get_for_model(model._meta.concrete_model) + custom_fields = list( + self.get_queryset().filter(object_types=content_type).select_related( + 'related_object_type', 'choice_set' + ) + ) - # Populate the request cache to avoid redundant lookups - if cache is not None: - cache['custom_fields'][model._meta.model] = custom_fields + # Populate the request cache to avoid redundant lookups + if cache is not None: + cache['custom_fields'][model._meta.model] = custom_fields - return custom_fields + return [cf for cf in custom_fields if cf.status in statuses] def get_defaults_for_model(self, model): """ Return a dictionary of serialized default values for all CustomFields applicable to the given model. + + Fields still being provisioned are included, unlike in get_for_model(). The provisioning job + backfills only the objects which predate the field, so an object created while it runs must + pick up the default here or never receive one at all. + + The defaults are assembled on each call from the fields cached by get_for_model() rather than + cached in their own right: building them costs a pass over a handful of objects already in + memory, where a second cache would have to be kept coherent with the first. """ - custom_fields = self.get_for_model(model).filter(default__isnull=False) + custom_fields = self.get_for_model(model, statuses=CustomFieldStatusChoices.DATA_STATUSES) + + # Copied so that a mutable default cannot be aliased into the object data of every object + # which takes it, the fields above being cached for the life of the request. return { - cf.name: cf.default for cf in custom_fields + cf.name: copy.deepcopy(cf.default) for cf in custom_fields if cf.default is not None } + @staticmethod + def clear_cache(): + """ + Discard the custom fields cached for the current request, so that a subsequent read reflects + a change which has been applied to the database without passing through save(). + + Called wherever a field's status is written directly (see CustomFieldStatusChoices): the + cache spans the whole of a request -- and the whole of a script or job run -- so a field + taken offline, brought live, or marked for deletion partway through one would otherwise + remain visible, or invisible, to everything which followed it there. + """ + if (cache := query_cache.get()) is not None: + cache['custom_fields'].clear() + class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedModel): object_types = models.ManyToManyField( @@ -136,6 +179,14 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo ), ) ) + status = models.CharField( + max_length=50, + choices=CustomFieldStatusChoices, + default=CustomFieldStatusChoices.STATUS_ACTIVE, + verbose_name=_('status'), + help_text=_("Operational state of the field"), + editable=False + ) label = models.CharField( verbose_name=_('label'), max_length=50, @@ -261,6 +312,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo verbose_name=_('is cloneable'), help_text=_('Replicate this value when cloning objects') ) + nulls_first = models.BooleanField( + default=True, + verbose_name=_('nulls first'), + help_text=_('Sort null values before non-null values when ordering by this field') + ) comments = models.TextField( verbose_name=_('comments'), blank=True @@ -272,6 +328,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo 'object_types', 'type', 'related_object_type', 'group_name', 'description', 'required', 'unique', 'search_weight', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'validation_schema', 'choice_set', 'ui_visible', 'ui_editable', 'is_cloneable', + 'nulls_first', ) class Meta: @@ -308,6 +365,9 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo return self.choice_set.choices return [] + def get_status_color(self): + return CustomFieldStatusChoices.colors.get(self.status) + def get_ui_visible_color(self): return CustomFieldUIVisibleChoices.colors.get(self.ui_visible) @@ -324,58 +384,226 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo return self.choice_set.get_choice_color(value) return None + def resolve_selection_value(self, value): + """ + For a Selection or Multiple selection field, wrap the value(s) with their resolved label as + {'value': ..., 'label': ...} (a list thereof for multi-select). Other field types pass through + unchanged. Shared by the REST API and GraphQL so selection labels resolve consistently (#20897). + """ + if value is None: + return value + if self.type == CustomFieldTypeChoices.TYPE_SELECT: + return {'value': value, 'label': self.get_choice_label(value)} + if self.type == CustomFieldTypeChoices.TYPE_MULTISELECT: + return [{'value': v, 'label': self.get_choice_label(v)} for v in value] + return value + @staticmethod - def _update_object_data(model, filters=None, **update_kwargs): + def data_lock_key(pk): """ - Apply an UPDATE to the custom_field_data of every instance of the given model in batches, - bounding the number of rows touched by each statement. A single unbounded UPDATE across - millions of rows can exceed the database statement timeout, because JSONB updates rewrite - each affected row in full. Batches are selected via keyset pagination on the primary key. - - The batched updates are wrapped in a transaction so that the operation remains atomic, as - it was when performed by a single UPDATE. This guards against partially-applied data (e.g. - a renamed field landing on only some objects) should the loop be interrupted when not - already running inside a request's transaction. Batching avoids the statement timeout - regardless, as that limit applies per statement rather than per transaction. - - :param filters: Optional dict of ORM filters restricting which rows are updated. Callers - which need only to touch rows already holding a given key should pass - `{'custom_field_data__has_key': ...}`; because keys are materialized only when a value - is actually set (see populate_initial_data()), this typically excludes the bulk of the - table. + The advisory lock which serializes bulk updates of a field's stored data against one another + and against its deletion, keyed by primary key so that work on one field never waits on + another. """ - filters = filters or {} - queryset = model.objects.filter(**filters) - with transaction.atomic(): - last_pk = 0 - while True: - pks = list( - queryset.filter(pk__gt=last_pk).order_by('pk') - .values_list('pk', flat=True)[:CUSTOMFIELD_DATA_BATCH_SIZE] + return ADVISORY_LOCK_KEYS['custom-field-data'], pk + + @classmethod + def _try_lock_data(cls, pk, using): + """ + Take the field's data lock at transaction scope, returning False if it is held elsewhere. + Never waits: a job holds this lock for the duration of its bulk update, which may run for + hours (see CUSTOMFIELD_JOB_TIMEOUT). + """ + with connections[using].cursor() as cursor: + cursor.execute('SELECT pg_try_advisory_xact_lock(%s, %s)', cls.data_lock_key(pk)) + return cursor.fetchone()[0] + + def _lock_status(self, using): + """ + Re-read the field's status under a row lock, returning None where the row no longer exists. + + The status is not taken from this instance, which a job or a concurrent request may have + changed since it was fetched, and which must not change between being checked by the caller + and the field being marked below. + """ + return self.__class__.objects.using(using).select_for_update().filter( + pk=self.pk + ).values_list('status', flat=True).first() + + @staticmethod + def _update_object_data(model, filters=None, commit_per_batch=False, **update_kwargs): + """ + Apply an UPDATE to the custom_field_data of every instance of the given model, in batches + of at most BULK_UPDATE_CHUNK_SIZE rows. Bounding the number of rows touched by each statement + keeps a very large table from exceeding the database statement timeout, as a JSONB update + rewrites each affected row in full. + + :param filters: Optional Q object restricting which rows are updated. Negate it to address + the rows which do not match instead. + :param commit_per_batch: Commit each batch independently rather than wrapping them all in a + single transaction, so that a long-running job does not hold row locks for its whole + duration. Only for updates which can safely be resumed. + """ + return chunked_update( + model.objects.filter(filters or Q()), + commit_per_batch=commit_per_batch, + **update_kwargs, + ) + + @staticmethod + def _exceeds_inline_limit(content_types): + """ + Return True if a bulk update of custom field data across the given object types is too large + to perform within the request which triggered it, and must be handed to a background job + instead. The limit is BULK_UPDATE_CHUNK_SIZE objects across all of the given types: an + update which fits within a single statement is comfortably within any request timeout. + + The rows are probed rather than counted: `COUNT(*)` reads the whole table, whereas counting + one primary key more than the limit costs the same on a table of ten million rows as on one + of ten thousand. Only the primary key is selected, and the model's default ordering cleared, + to keep the probe to an index-only scan. + + On the deletion path this over-estimates, as every row of the type is counted where + remove_stale_data() would rewrite only those holding the field's key. Probing the key + instead would match the work exactly, but custom_field_data carries no index, so the LIMIT + could not bound the scan. + """ + # Setting BULK_UPDATE_CHUNK_SIZE to None disables chunking, so the update would be issued + # as a single unbounded statement -- precisely what must not run inside a request. Treat any + # affected object as exceeding the limit, handing the work to the job, which issues that one + # statement under a timeout generous enough to survive it (see CUSTOMFIELD_JOB_TIMEOUT). A + # limit of zero leaves the probe below testing for a single row, so a field affecting no + # objects still needs no job. + limit = settings.BULK_UPDATE_CHUNK_SIZE + remaining = 0 if limit is None else limit + + for ct in content_types: + if model := ct.model_class(): + remaining -= model.objects.order_by().values_list('pk', flat=True)[:remaining + 1].count() + if remaining < 0: + return True + return False + + def provision_data(self, object_types): + """ + Populate the field's default value across the existing objects of the given object types. + + Where too many objects are affected to handle within the request, the field is taken offline + and the backfill handed to a background job: it does not go live until the job has finished + (see CustomFieldStatusChoices). + + Assignment to a field which is not live is refused, as CustomField.clean() refuses every + other change to one: its configuration must not move under the job which is acting on it. + Were a second backfill deferred here, it would carry only the object types passed to it, and + whichever of the two jobs ran first would bring the field live -- leaving the other to find + a field it no longer matched, and its own object types silently unprovisioned. + """ + from extras.jobs import CustomFieldProvisioningJob + + using = router.db_for_write(self.__class__, instance=self) + + with transaction.atomic(using=using): + + # The status is re-read under a row lock rather than taken from this instance + self.status = self._lock_status(using) + if self.status is None: + # Deleted by a concurrent request since this instance was fetched; there is no field + # left to assign. Reported rather than ignored, as the assignment has not been applied. + raise AbortRequest( + _("Custom field '{name}' no longer exists.").format(name=self.name) ) - if not pks: - break - queryset.filter(pk__in=pks).update(**update_kwargs) - last_pk = pks[-1] - def populate_initial_data(self, content_types): + if self.status != CustomFieldStatusChoices.STATUS_ACTIVE: + raise AbortRequest( + _("Custom field '{name}' cannot be assigned to additional object types while its " + "stored data is being updated (status: {status}).").format( + name=self.name, status=self.get_status_display().lower() + ) + ) + + if self.default is None: + return + + object_types = list(object_types) + if not self._exceeds_inline_limit(object_types): + self.populate_initial_data(object_types) + return + + self.status = CustomFieldStatusChoices.STATUS_PROVISIONING + # Applied via the queryset so that taking the field offline does not itself record a change. + self.__class__.objects.using(using).filter(pk=self.pk).update(status=self.status) + self.__class__.objects.clear_cache() + + # Deferred until commit so that the worker cannot observe the field before it is marked. + # The types are carried to the job, which cannot otherwise know which of the field's + # assignments are the new ones. + transaction.on_commit( + lambda: CustomFieldProvisioningJob.enqueue_for( + self, object_type_pks=[ct.pk for ct in object_types] + ), + using=using + ) + + def remove_data(self, object_types): + """ + Remove the field's stored data from the existing objects of the given object types, as the + field is unassigned from them. + + Unassignment from a field which is not live is refused, as provision_data() refuses an + assignment to one. The job acting on the field's data carries the object types it was given + and would not observe an unassignment made under it: it would write its defaults into objects + the removal had already swept, then bring the field live with values left on objects it no + longer applies to. + + Unlike provisioning and deletion, this is never deferred to a job. Only the objects which + actually hold a value for the field are rewritten, which on an unassignment is typically a + small fraction of the table (see the note in the custom fields documentation). + """ + using = router.db_for_write(self.__class__, instance=self) + + with transaction.atomic(using=using): + + # The status is re-read under a row lock rather than taken from this instance, which a + # job may have taken offline since it was fetched, and which must not change between the + # check below and the data being removed. + self.status = self._lock_status(using) + + if self.status is None: + # Deleted by a concurrent request since this instance was fetched; whatever data + # remains belongs to the deletion, which removes it in full. + raise AbortRequest( + _("Custom field '{name}' no longer exists.").format(name=self.name) + ) + + if self.status != CustomFieldStatusChoices.STATUS_ACTIVE: + raise AbortRequest( + _("Custom field '{name}' cannot be unassigned from object types while its " + "stored data is being updated (status: {status}).").format( + name=self.name, status=self.get_status_display().lower() + ) + ) + + self.remove_stale_data(object_types) + + def populate_initial_data(self, content_types, commit_per_batch=False): """ Populate initial custom field data upon either a) the creation of a new CustomField, or b) the assignment of an existing CustomField to new object types. - Only a non-null default is written. A field with no default has no value to record, and an - absent key is equivalent to a null one everywhere the data is read (see CustomFieldsMixin), - so materializing a JSON null on every object would be a very expensive no-op: on a large - table it can outlast the request. Objects without the key simply report no value until one - is assigned. + Objects which already hold a key for the field are left alone, making this idempotent -- as + a retried job requires, and as committing the backfill in batches relies on. (Note that a + cleared value is a JSON null rather than an absent key, and so is likewise preserved.) """ if self.default is None: return + value = Value(self.default, models.JSONField()) for ct in content_types: if model := ct.model_class(): self._update_object_data( model, + filters=~Q(custom_field_data__has_key=self.name), + commit_per_batch=commit_per_batch, custom_field_data=Func( F('custom_field_data'), Value([self.name]), @@ -384,20 +612,21 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo ) ) - def remove_stale_data(self, content_types): + def remove_stale_data(self, content_types, commit_per_batch=False): """ Delete custom field data which is no longer relevant (either because the CustomField is no longer assigned to a model, or because it has been deleted). - Only objects which actually hold a value for the field are rewritten. Because keys are - materialized only when a value is set (see populate_initial_data()), this typically - excludes the bulk of the table. + Only objects which actually hold a value for the field are rewritten. That typically excludes + the bulk of the table, and makes this idempotent -- as committing the removal in batches + relies on -- since a row is dropped from the queryset by the update which removes its key. """ for ct in content_types: if model := ct.model_class(): self._update_object_data( model, - filters={'custom_field_data__has_key': self.name}, + filters=Q(custom_field_data__has_key=self.name), + commit_per_batch=commit_per_batch, custom_field_data=F('custom_field_data') - self.name ) @@ -410,7 +639,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo if model := ct.model_class(): self._update_object_data( model, - filters={'custom_field_data__has_key': old_name}, + filters=Q(custom_field_data__has_key=old_name), custom_field_data=Func( F('custom_field_data') - old_name, Value([new_name]), @@ -423,9 +652,95 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo function='jsonb_set') ) + def delete(self, using=None, *args, **kwargs): + """ + Delete the field, deferring the removal of its stored data to a background job where too + many objects are affected to handle within the request (see #22996). + + Where the work is deferred, the row is retained until the job completes: `name` is unique, so + for as long as the row exists no other field can take this name and inherit the data still + awaiting removal. + + The deletion signals are dispatched here rather than when the row is finally removed, so that + protection rules, the change log, event rules and the search index observe the deletion where + the user performed it. They run again in the worker, where every effect beyond the protection + rules is gated on there being a current request, making the replay a no-op. + + The deletion is refused outright if a background job holds the field's data lock, rather than + queueing behind that job. This applies equally to a field already pending deletion: reporting + a deletion which did not happen would be worse than refusing it. A field stranded in a pending + state by a job which never ran holds no lock, and stays deletable; retrying the deletion of + one already pending enqueues a fresh purge job for it. + + Deleting a field already marked for deletion -- by an earlier request of the user's own, or by + a concurrent one -- removes nothing further and dispatches no second set of deletion signals. + """ + from extras.jobs import CustomFieldPurgeJob + + using = using or router.db_for_write(self.__class__, instance=self) + + with transaction.atomic(using=using): + if not self._try_lock_data(self.pk, using): + raise AbortRequest( + _("Custom field '{name}' is being updated by a background job and cannot be " + "deleted until that job has completed.").format(name=self.name) + ) + + # The status is re-read under a row lock rather than taken from this instance + self.status = self._lock_status(using) + if self.status is None: + # Already deleted outright by a concurrent request; nothing remains to delete. + return 0, {} + + if self.status == CustomFieldStatusChoices.STATUS_DELETING: + # Already pending deletion; the purge job will remove the row once its data is gone. + # The lock being free, no job is *running*, so the one enqueued when the field was + # marked may never have run: enqueue another, delete() being the only route to one. + # Left as it is, a field whose job never ran could never be removed, and would hold + # its name against a replacement indefinitely. Where that job is merely queued (a + # concurrent deletion having just marked the field), the second job is harmless: + # purge_custom_field() rechecks the status under the lock and no-ops. + transaction.on_commit(lambda: CustomFieldPurgeJob.enqueue_for(self), using=using) + return 0, {} + + if not self._exceeds_inline_limit(self.object_types.all()): + # Few enough objects to purge within the request: delete the row outright, its + # stored data being removed by handle_cf_deleted(). + return super().delete(using, *args, **kwargs) + + # Update the custom field's status before the signals are dispatched. Applied via the + # queryset to avoid emitting a spurious "updated" change record. + self.status = CustomFieldStatusChoices.STATUS_DELETING + self.__class__.objects.using(using).filter(pk=self.pk).update(status=self.status) + self.__class__.objects.clear_cache() + + models.signals.pre_delete.send(sender=self.__class__, instance=self, using=using, origin=self) + models.signals.post_delete.send(sender=self.__class__, instance=self, using=using, origin=self) + + # Deferred until commit so that the worker cannot observe the field before it is marked, + # and is not enqueued at all if the deletion is aborted. + transaction.on_commit(lambda: CustomFieldPurgeJob.enqueue_for(self), using=using) + + return 1, {self._meta.label: 1} + + def _delete_row(self): + """ + Remove the row itself. Called by CustomFieldPurgeJob once the field's stored data has been + purged; nothing else should bypass delete(). + """ + return super().delete() + def clean(self): super().clean() + # A field awaiting a bulk update of its stored data is not live, and its configuration must + # not change under the job which is acting on it. + if self.pk and self.status != CustomFieldStatusChoices.STATUS_ACTIVE: + raise ValidationError( + _("Custom field '{name}' cannot be modified while its stored data is being updated " + "(status: {status}).").format(name=self.name, status=self.get_status_display().lower()) + ) + # Validate the field's default value (if any) if self.default is not None: try: @@ -818,6 +1133,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo elif self.type == CustomFieldTypeChoices.TYPE_URL: if type(value) is not str: raise ValidationError(_("Value must be a string.")) + # Enforce ALLOWED_URL_SCHEMES to guard against dangerous schemes (e.g. javascript:). A + # schemeless value is permitted and treated as relative. + if not url_scheme_is_allowed(value): + raise ValidationError( + _("URLs must use a scheme permitted by ALLOWED_URL_SCHEMES.") + ) if self.validation_regex and not re.match(self.validation_regex, value): raise ValidationError(_("Value must match regex '{regex}'").format(regex=self.validation_regex)) @@ -883,7 +1204,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo # Validate all selected choices elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT: - if not set(value).issubset(self.choice_set.values): + # Require a list of valid string choices. The isinstance() check short-circuits the membership + # test so that non-string members (e.g. a client echoing back the {value, label} read + # representation) raise a ValidationError rather than an unhashable-type TypeError. + valid_values = set(self.choice_set.values) + if type(value) is not list or not all(isinstance(v, str) and v in valid_values for v in value): raise ValidationError( _("Invalid choice(s) ({value}) for choice set {choiceset}.").format( value=value, diff --git a/netbox/extras/models/mixins.py b/netbox/extras/models/mixins.py index 8ec07bcfb..bb7b2dec1 100644 --- a/netbox/extras/models/mixins.py +++ b/netbox/extras/models/mixins.py @@ -1,5 +1,6 @@ import importlib.abc import importlib.util +import logging import os import sys from collections import defaultdict @@ -22,6 +23,8 @@ __all__ = ( 'RenderTemplateMixin', ) +logger = logging.getLogger(__name__) + class CustomStoragesLoader(importlib.abc.Loader): """ @@ -128,6 +131,10 @@ class RenderTemplateMixin(models.Model): abstract = True def get_context(self, context=None, queryset=None): + from django.apps import apps as django_apps + + from netbox.plugins import PluginConfig + _context = defaultdict(dict) # Populate all public models for reference within the template @@ -135,6 +142,14 @@ class RenderTemplateMixin(models.Model): if model := object_type.model_class(): _context[object_type.app_label][model.__name__] = model + # Allow plugins to inject additional context (e.g. friendly-named namespaces) + for app_config in django_apps.get_app_configs(): + if isinstance(app_config, PluginConfig): + try: + _context.update(app_config.get_jinja_context()) + except Exception: + logger.exception("Plugin %r raised an exception in get_jinja_context()", app_config.name) + if context is not None: _context.update(context) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 40e5d8745..75b150dda 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -6,12 +6,14 @@ from pathlib import Path from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.postgres.fields import ArrayField -from django.core.validators import ValidationError +from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse from django.utils import timezone from django.utils.html import escape from django.utils.safestring import mark_safe +from django.utils.text import format_lazy from django.utils.translation import gettext_lazy as _ from rest_framework.utils.encoders import JSONEncoder @@ -22,6 +24,7 @@ from extras.models.mixins import RenderTemplateMixin from extras.querysets import SharedObjectQuerySet from extras.utils import image_upload from netbox.config import get_config +from netbox.event_rules import get_event_rule_action, get_event_rule_action_choices from netbox.events import get_event_type_choices from netbox.models import ChangeLoggedModel from netbox.models.features import ( @@ -34,6 +37,7 @@ from netbox.models.features import ( has_feature, ) from netbox.models.mixins import OwnerMixin +from netbox.settings_utils import parse_job_timeout from utilities.html import clean_html from utilities.jinja2 import JINJA2_TEMPLATE_RE, render_jinja2, sanitize_http_header, validate_jinja2_syntax from utilities.querydict import dict_to_querydict @@ -97,15 +101,19 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, OwnerMixin, TagsMixin, # Action to take action_type = models.CharField( - max_length=30, - choices=EventRuleActionChoices, + max_length=100, + # Bare callable, re-evaluated fresh on each access via Django's CallableChoiceIterator, + # so a plugin action registered after this module was first imported is still reflected. + choices=get_event_rule_action_choices, default=EventRuleActionChoices.WEBHOOK, verbose_name=_('action type') ) action_object_type = models.ForeignKey( to='contenttypes.ContentType', related_name='eventrule_actions', - on_delete=models.CASCADE + on_delete=models.CASCADE, + blank=True, + null=True, ) action_object_id = models.PositiveBigIntegerField( blank=True, @@ -140,6 +148,26 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, OwnerMixin, TagsMixin, def get_absolute_url(self): return reverse('extras:eventrule', args=[self.pk]) + @property + def action_provider(self): + """ + Return the registered EventRuleAction instance for this rule's action_type, or None if it + is not currently registered (e.g. the providing plugin is not installed). + """ + return get_event_rule_action(self.action_type) + + @property + def action_is_available(self): + return self.action_provider is not None + + def get_action_type_display(self): + if action := self.action_provider: + return action.label + return _('{slug} (unavailable)').format(slug=self.action_type) + + def get_action_type_color(self): + return None if self.action_is_available else 'red' + def clean(self): super().clean() @@ -154,6 +182,11 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, OwnerMixin, TagsMixin, if self.action_data is not None and not isinstance(self.action_data, dict): raise ValidationError({'action_data': _('Action data must be a JSON object or null.')}) + # action_type's own validity is already enforced by the field's dynamic choices= (Field. + # validate(), earlier in full_clean()); guard here only in case clean() ran standalone. + if self.action_is_available: + self.action_provider._validate(action_object=self.action_object, action_data=self.action_data) + def eval_conditions(self, data): """ Test whether the given data meets the conditions of the event rule (if any). Return True @@ -230,7 +263,7 @@ class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, OwnerMixin, Ch help_text=_( "Jinja2 template for a custom request body. If blank, a JSON object representing the change will be " "included. Available context data includes: event, model, " - "timestamp, username, request_id, and data." + "timestamp, request, and data." ) ) secret = models.CharField( @@ -256,6 +289,22 @@ class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, OwnerMixin, Ch "The specific CA certificate file to use for SSL verification. Leave blank to use the system defaults." ) ) + timeout = models.PositiveSmallIntegerField( + verbose_name=_('timeout'), + null=True, + blank=True, + validators=( + MinValueValidator(1), + MaxValueValidator(3600), + ), + help_text=format_lazy( + _( + "The maximum time (in seconds) to wait for a response before failing the request. Leave blank to use " + "the system default ({default_timeout} seconds)." + ), + default_timeout=settings.WEBHOOK_DEFAULT_TIMEOUT + ) + ) events = GenericRelation( EventRule, content_type_field='action_object_type', @@ -315,6 +364,17 @@ class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, OwnerMixin, Ch if errors: raise ValidationError(errors) + # A timeout which meets or exceeds the background job timeout leaves no room for the request's own timeout + # to apply: the worker will terminate the job first. (Staying below the job timeout does not guarantee that + # the request times out on its own, as the timeout applies separately to connecting and to reading data.) + job_timeout = parse_job_timeout(settings.RQ_DEFAULT_TIMEOUT) + if self.timeout is not None and job_timeout is not None and self.timeout >= job_timeout: + raise ValidationError({ + 'timeout': _( + "Timeout must be less than the background job timeout ({timeout} seconds)." + ).format(timeout=job_timeout) + }) + def render_headers(self, context): """ Render additional_headers and return a dict of Header: Value pairs. diff --git a/netbox/extras/models/notifications.py b/netbox/extras/models/notifications.py index c2e30c697..5d21a342d 100644 --- a/netbox/extras/models/notifications.py +++ b/netbox/extras/models/notifications.py @@ -12,6 +12,7 @@ from netbox.models import ChangeLoggedModel from netbox.models.features import has_feature from netbox.registry import registry from users.models import User +from utilities.choices import Choice from utilities.querysets import RestrictedQuerySet __all__ = ( @@ -26,7 +27,7 @@ def get_event_type_choices(): Compile a list of choices from all registered event types """ return [ - (name, event.text) + Choice(name, event.text) for name, event in registry['event_types'].items() ] diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index 363deeb65..cc0df7dee 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -1,5 +1,5 @@ from django.contrib.postgres.aggregates import JSONBAgg -from django.db.models import OuterRef, Q, Subquery +from django.db.models import Case, JSONField, OuterRef, Q, Subquery, When from extras.models.tags import TaggedItem from utilities.query_functions import EmptyGroupByJSONBAgg @@ -19,6 +19,10 @@ class ConfigContextQuerySet(RestrictedQuerySet): """ Return all applicable ConfigContexts for a given object. Only active ConfigContexts will be included. + WARNING: This method's scope-matching logic is mirrored (inverted) by ConfigContext.get_affected_objects(), + which powers cache invalidation. Any change to the matching criteria here MUST be applied there as well, or + pre-rendered config context caches will go stale. See extras/models/configs.py. + Args: aggregate_data: If True, use the JSONBAgg aggregate function to return only the list of JSON data objects """ @@ -70,7 +74,7 @@ class ConfigContextQuerySet(RestrictedQuerySet): if aggregate_data: return queryset.aggregate( - config_context_data=JSONBAgg('data', ordering=['weight', 'name']) + config_context_data=JSONBAgg('data', order_by=['weight', 'name']) )['config_context_data'] return queryset @@ -85,20 +89,39 @@ class ConfigContextModelQuerySet(RestrictedQuerySet): This offers a substantial performance gain over ConfigContextQuerySet.get_for_object() when dealing with multiple objects. This allows the annotation to be entirely optional. """ - def annotate_config_context_data(self): + def annotate_config_context_data(self, only_invalidated=False): """ - Attach the subquery annotation to the base queryset + Attach the subquery annotation to the base queryset. + + Args: + only_invalidated: If True, evaluate the (expensive) aggregation subquery only for rows + whose pre-rendered cache (`_config_context_data`) is NULL, returning NULL for rows + that already have a populated cache. This is the list/detail read-path optimization: + warm rows are served from the cache by ConfigContextModel.get_config_context() and + never consult this annotation, so computing it for them is wasted work. PostgreSQL + short-circuits CASE branches, so the correlated SubPlan is not executed for warm + rows. + + NOTE: With only_invalidated=True the annotation is NULL for warm rows. It is only + safe to read via get_config_context() (which short-circuits on the cache before + touching the annotation). Do NOT call render_config_context() directly on a row + annotated this way, or a warm row would render an empty context. """ from extras.models import ConfigContext - return self.annotate( - config_context_data=Subquery( - ConfigContext.objects.filter( - self._get_config_context_filters() - ).annotate( - _data=EmptyGroupByJSONBAgg('data', order_by=['weight', 'name']) - ).values("_data").order_by() - ) + subquery = Subquery( + ConfigContext.objects.filter( + self._get_config_context_filters() + ).annotate( + _data=EmptyGroupByJSONBAgg('data', order_by=['weight', 'name']) + ).values("_data").order_by() ) + if only_invalidated: + subquery = Case( + When(_config_context_data__isnull=True, then=subquery), + default=None, + output_field=JSONField(), + ) + return self.annotate(config_context_data=subquery) def _get_config_context_filters(self): # Construct the set of Q objects for the specific object types @@ -131,10 +154,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet): if self.model._meta.model_name == 'device': base_query.add( (Q( - locations__tree_id=OuterRef('location__tree_id'), - locations__level__lte=OuterRef('location__level'), - locations__lft__lte=OuterRef('location__lft'), - locations__rght__gte=OuterRef('location__rght'), + locations__path__ancestor_or_equal=OuterRef('location__path'), ) | Q(locations=None)), Q.AND ) @@ -143,40 +163,29 @@ class ConfigContextModelQuerySet(RestrictedQuerySet): base_query.add(Q(locations=None), Q.AND) base_query.add(Q(device_types=None), Q.AND) - # MPTT-based filters + # Ltree-based filters: the ConfigContext-side tree node must be an ancestor + # (or equal to) the device/VM-side tree node, i.e. `cc_node.path @> obj_node.path`. base_query.add( (Q( - regions__tree_id=OuterRef('site__region__tree_id'), - regions__level__lte=OuterRef('site__region__level'), - regions__lft__lte=OuterRef('site__region__lft'), - regions__rght__gte=OuterRef('site__region__rght'), + regions__path__ancestor_or_equal=OuterRef('site__region__path'), ) | Q(regions=None)), Q.AND ) base_query.add( (Q( - site_groups__tree_id=OuterRef('site__group__tree_id'), - site_groups__level__lte=OuterRef('site__group__level'), - site_groups__lft__lte=OuterRef('site__group__lft'), - site_groups__rght__gte=OuterRef('site__group__rght'), + site_groups__path__ancestor_or_equal=OuterRef('site__group__path'), ) | Q(site_groups=None)), Q.AND ) base_query.add( (Q( - roles__tree_id=OuterRef('role__tree_id'), - roles__level__lte=OuterRef('role__level'), - roles__lft__lte=OuterRef('role__lft'), - roles__rght__gte=OuterRef('role__rght'), + roles__path__ancestor_or_equal=OuterRef('role__path'), ) | Q(roles=None)), Q.AND ) base_query.add( (Q( - platforms__tree_id=OuterRef('platform__tree_id'), - platforms__level__lte=OuterRef('platform__level'), - platforms__lft__lte=OuterRef('platform__lft'), - platforms__rght__gte=OuterRef('platform__rght'), + platforms__path__ancestor_or_equal=OuterRef('platform__path'), ) | Q(platforms=None)), Q.AND ) diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 417192dc3..181554fb1 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -1,9 +1,10 @@ from django.contrib.contenttypes.models import ContentType -from django.db.models.signals import m2m_changed, post_save, pre_delete +from django.db.models.signals import m2m_changed, post_delete, post_save, pre_delete from django.dispatch import receiver from core.events import * from core.signals import job_end, job_start +from extras.choices import CustomFieldStatusChoices from extras.events import EventContext, process_event_rules from extras.models import EventRule, Notification, Subscription from netbox.config import get_config @@ -12,7 +13,13 @@ from netbox.signals import post_clean from utilities.data import get_config_value_ci from utilities.exceptions import AbortRequest -from .models import CustomField, TaggedItem +from .cache import ( + invalidate_config_context_for_configcontext, + invalidate_config_context_for_objects, + invalidate_for_scope_delta, +) +from .constants import CC_FIELDS_BY_MODEL +from .models import ConfigContext, CustomField, TaggedItem from .utils import run_validators # @@ -27,26 +34,31 @@ def handle_cf_object_types_changed(instance, action, pk_set, reverse, **kwargs): Only the forward direction is handled: every action below operates on the CustomField, whereas the reverse of this relation (ContentType.custom_fields) reports the ContentType as the sender's instance. Nothing in NetBox assigns object types that way. + + Both unassignment actions are handled before the fact, so that remove_data() refusing the change + precedes the removal of the assignments themselves. Django wraps each of these operations in a + transaction, so the refusal would roll the removal back in any case -- but only where the caller + left that transaction to it. """ - if reverse or action not in ('pre_clear', 'post_add', 'post_remove'): + if reverse or action not in ('pre_clear', 'post_add', 'pre_remove'): return if action == 'pre_clear': - # clear() unassigns every object type at once. It must be handled before the fact: no - # pk_set is reported for a clear, so the assignments have to be read while they still - # exist. (Note that set() diffs via remove()/add() by default, so it does not land here.) - instance.remove_stale_data(instance.object_types.all()) + # clear() unassigns every object type at once, and reports no pk_set, so the assignments + # have to be read while they still exist. (Note that set() diffs via remove()/add() by + # default, so it does not land here.) + instance.remove_data(instance.object_types.all()) return object_types = ContentType.objects.filter(pk__in=pk_set) if action == 'post_add': - # Populate the field's default value (if any) on all existing objects - instance.populate_initial_data(object_types) - + # Populate the field's default value (if any) on the existing objects of the types just + # assigned. + instance.provision_data(object_types) else: - # Remove the field's stored data from objects to which it no longer applies - instance.remove_stale_data(object_types) + # Remove the field's stored data from objects to which it no longer applies. + instance.remove_data(object_types) def handle_cf_renamed(instance, created, **kwargs): @@ -60,14 +72,41 @@ def handle_cf_renamed(instance, created, **kwargs): def handle_cf_deleted(instance, **kwargs): """ Handle the cleanup of old custom field data when a CustomField is deleted. + + A field already marked for deletion is skipped: its data is too voluminous to purge inline, and + CustomFieldPurgeJob is removing it (see CustomField.delete()). """ - instance.remove_stale_data(instance.object_types.all()) + if instance.status != CustomFieldStatusChoices.STATUS_DELETING: + instance.remove_stale_data(instance.object_types.all()) + + +def handle_cf_cache_invalidation(action=None, **kwargs): + """ + Discard the custom fields cached for the current request whenever one is created, modified, + deleted, or (un)assigned from an object type. + + The cache spans the whole of a request -- and the whole of a script or job run, which share one + for their entire duration -- so without this a field created or changed partway through would be + served from what was read before it, to everything which followed. + + A field's status is written via the queryset and so reaches none of these signals; the paths + which write it clear the cache themselves (see CustomFieldManager.clear_cache). + """ + # m2m_changed fires either side of the change; clear once it has actually been applied. + if action is not None and not action.startswith('post_'): + return + + CustomField.objects.clear_cache() post_save.connect(handle_cf_renamed, sender=CustomField) pre_delete.connect(handle_cf_deleted, sender=CustomField) m2m_changed.connect(handle_cf_object_types_changed, sender=CustomField.object_types.through) +post_save.connect(handle_cf_cache_invalidation, sender=CustomField) +post_delete.connect(handle_cf_cache_invalidation, sender=CustomField) +m2m_changed.connect(handle_cf_cache_invalidation, sender=CustomField.object_types.through) + # # Custom validation @@ -102,6 +141,296 @@ def validate_assigned_tags(sender, instance, action, model, pk_set, **kwargs): raise AbortRequest(f"Tag {tag} cannot be assigned to {ct.model} objects.") +# +# Config context cache invalidation +# + +@receiver(post_save, sender=ConfigContext) +def invalidate_on_configcontext_save(sender, instance, using=None, **kwargs): + """ + Whenever a ConfigContext's scalar fields change (e.g. `data`, `weight`, `is_active`), + invalidate the caches of all Devices/VMs currently in scope. M2M scope changes are handled + separately by invalidate_on_configcontext_m2m_change(). + """ + invalidate_config_context_for_configcontext(instance, using=using) + + +@receiver(pre_delete, sender=ConfigContext) +def invalidate_on_configcontext_delete(sender, instance, using=None, **kwargs): + """ + Before a ConfigContext is deleted, invalidate the caches of all Devices/VMs currently in + scope. The scope is still readable here (pre_delete fires before the row and its M2M rows + are removed). + """ + invalidate_config_context_for_configcontext(instance, using=using) + + +def invalidate_on_configcontext_m2m_change(sender, instance, action, pk_set, scope_field, using=None, **kwargs): + """ + Whenever a ConfigContext's scope M2M changes, invalidate the caches of all Devices/VMs that + were or now are in scope. + + Strategy: + - For post_add: the current scope is broader than (or equal to) the previous scope. Devices + newly in scope are caught by invalidating the current affected set. + - For post_remove: the current scope is narrower. We must also invalidate devices that + matched only via the just-removed scope items. + - For post_clear: the scope is now empty (matches all). The current full affected set is the + broadest possible for this attribute; invalidating it suffices. + """ + if action not in ('post_add', 'post_remove', 'post_clear'): + return + + # Always invalidate based on the current (post-change) scope. + invalidate_config_context_for_configcontext(instance, using=using) + + # For post_remove, also invalidate devices/VMs that matched via the removed scope items. + if action == 'post_remove' and pk_set: + invalidate_for_scope_delta(scope_field, pk_set, using=using) + + +def _connect_configcontext_m2m_handlers(): + """ + Wire `invalidate_on_configcontext_m2m_change` to every ConfigContext scope M2M's through + model. The set of scope M2Ms is introspected from the model so new ones are picked up + automatically. The receiver is curried with `scope_field` to identify which attribute changed. + """ + for m2m_field in ConfigContext._meta.many_to_many: + field_name = m2m_field.name + through = getattr(ConfigContext, field_name).through + + def _handler(sender, instance, action, pk_set, using=None, _field=field_name, **kwargs): + invalidate_on_configcontext_m2m_change( + sender=sender, + instance=instance, + action=action, + pk_set=pk_set, + scope_field=_field, + using=using, + **kwargs, + ) + + m2m_changed.connect(_handler, sender=through, weak=False) + + +_connect_configcontext_m2m_handlers() + + +def _changed_fields(instance, fields): + """ + Return True if any of `fields` differs between the prechange snapshot and the current state. + If no snapshot exists (e.g. object loaded fresh from DB and saved without a snapshot), assume + we cannot tell what changed and conservatively return True. The cost is one extra background + re-render per non-instrumented save; the cost of returning False would be stale caches. + """ + snapshot = getattr(instance, '_prechange_snapshot', None) + if not snapshot: + return True + for field in fields: + # Snapshot keys mirror Django's JSON serializer: FK ids are stored under the bare name + # (no `_id` suffix). Convert. + snap_key = field[:-3] if field.endswith('_id') else field + if snapshot.get(snap_key) != getattr(instance, field, None): + return True + return False + + +def _make_object_save_handler(model_label): + fields = CC_FIELDS_BY_MODEL[model_label] + + def _handler(sender, instance, created, using=None, **kwargs): + # On creation, enqueue a render so the new object's cache is warmed promptly (there is no + # recurring sweep). On update, only invalidate when a scope-relevant field actually changed. + if created or _changed_fields(instance, fields): + invalidate_config_context_for_objects(model_label, [instance.pk], using=using) + + return _handler + + +def _connect_object_save_handlers(): + from django.apps import apps as django_apps + + for model_label in CC_FIELDS_BY_MODEL: + Model = django_apps.get_model(model_label) + post_save.connect(_make_object_save_handler(model_label), sender=Model, weak=False) + + +_connect_object_save_handlers() + + +@receiver(m2m_changed, sender=TaggedItem) +def invalidate_on_device_vm_tag_change(sender, instance, action, using=None, **kwargs): + """ + When tags are added or removed on a Device/VM, invalidate that object's cache. + """ + if action not in ('post_add', 'post_remove', 'post_clear'): + return + from dcim.models import Device + from virtualization.models import VirtualMachine + + if isinstance(instance, Device): + invalidate_config_context_for_objects('dcim.device', [instance.pk], using=using) + elif isinstance(instance, VirtualMachine): + invalidate_config_context_for_objects('virtualization.virtualmachine', [instance.pk], using=using) + + +# Upstream object changes that affect ConfigContext matching even when the Device/VM itself is +# untouched. Two patterns are handled: +# +# 1. Direct FK changes (Site.region, Cluster.type, Tenant.group, ...): invalidate the caches of +# Devices/VMs that reference the changed object. +# 2. Ltree reparents (Region.parent, SiteGroup.parent, ...): invalidate every Device/VM whose +# attribute resolves into the changed node's subtree, because the ancestor list used by the +# matching query has shifted. + + +def _make_direct_upstream_handler(fields, device_lookup, vm_lookup): + def _handler(sender, instance, created, using=None, **kwargs): + if created or not _changed_fields(instance, fields): + return + from dcim.models import Device + from virtualization.models import VirtualMachine + + if device_lookup: + invalidate_config_context_for_objects( + 'dcim.device', + Device.objects.using(using).filter(**{device_lookup: instance.pk}).values_list('pk', flat=True), + using=using, + ) + if vm_lookup: + invalidate_config_context_for_objects( + 'virtualization.virtualmachine', + VirtualMachine.objects.using(using).filter(**{vm_lookup: instance.pk}).values_list('pk', flat=True), + using=using, + ) + + return _handler + + +def _make_reparent_handler(device_attr, vm_attr): + def _handler(sender, instance, created, using=None, **kwargs): + if created or not _changed_fields(instance, ('parent_id',)): + return + from dcim.models import Device + from virtualization.models import VirtualMachine + + # The ltree triggers rewrite `path` server-side during the UPDATE, but LtreeModel.save() + # only refreshes the in-memory value AFTER post_save fires — so `instance.path` is still + # the pre-move value here. Re-read the node's current path from the DB to enumerate its + # (post-move) subtree. The set of node PKs is invariant under a move; only their paths + # shift, so this matches the same Devices/VMs regardless of timing. + model = type(instance) + node_path = model.objects.using(using).filter(pk=instance.pk).values_list('path', flat=True).first() + if node_path is None: + return + subtree_pks = list( + model.objects.using(using).filter(path__descendant_or_equal=node_path).values_list('pk', flat=True) + ) + + if device_attr: + invalidate_config_context_for_objects( + 'dcim.device', + Device.objects.using(using).filter(**{device_attr: subtree_pks}).values_list('pk', flat=True), + using=using, + ) + if vm_attr: + invalidate_config_context_for_objects( + 'virtualization.virtualmachine', + VirtualMachine.objects.using(using).filter(**{vm_attr: subtree_pks}).values_list('pk', flat=True), + using=using, + ) + + return _handler + + +def _connect_upstream_handlers(): + from django.apps import apps as django_apps + + # (app, model, fields_to_watch, device_lookup, vm_lookup) + direct_triggers = ( + ('dcim', 'Site', ('region_id', 'group_id'), 'site_id', 'site_id'), + ('dcim', 'Location', ('site_id',), 'location_id', None), + # Cluster's effective site is the cached `_site_id`, not a `site` FK; it is what + # virtualization.signals propagates to the cluster's VMs, shifting their site matching. + ('virtualization', 'Cluster', ('type_id', 'group_id', '_site_id'), 'cluster_id', 'cluster_id'), + ('tenancy', 'Tenant', ('group_id',), 'tenant_id', 'tenant_id'), + ) + for app, name, fields, device_lookup, vm_lookup in direct_triggers: + Model = django_apps.get_model(app, name) + post_save.connect( + _make_direct_upstream_handler(fields, device_lookup, vm_lookup), + sender=Model, + weak=False, + ) + + # (app, model, device_attr_path__in, vm_attr_path__in) + reparent_triggers = ( + ('dcim', 'Region', 'site__region__in', 'site__region__in'), + ('dcim', 'SiteGroup', 'site__group__in', 'site__group__in'), + ('dcim', 'DeviceRole', 'role__in', 'role__in'), + ('dcim', 'Platform', 'platform__in', 'platform__in'), + ('dcim', 'Location', 'location__in', None), + ) + for app, name, device_attr, vm_attr in reparent_triggers: + Model = django_apps.get_model(app, name) + post_save.connect( + _make_reparent_handler(device_attr, vm_attr), + sender=Model, + weak=False, + ) + + +_connect_upstream_handlers() + + +# Deletion of an upstream object referenced by a Device/VM via a SET_NULL foreign key (or by a +# Site/Tenant the object belongs to) silently nulls that FK with a bulk UPDATE that emits no +# post_save signal, so the object-save handlers above never fire. We therefore invalidate on +# pre_delete, while the references are still resolvable. +# +# Only SET_NULL relationships matter here: PROTECT relationships (Device.role/tenant/site, +# VM.cluster/site/role/tenant, etc.) cannot be deleted while a Device/VM references them, so no +# stale cache can result. The SET_NULL feeders into ConfigContext matching are: +# - Platform (Device.platform, VM.platform) +# - Cluster (Device.cluster) -> also covers cluster_type/cluster_group scopes +# - Region (Site.region) +# - SiteGroup (Site.group) +# - TenantGroup (Tenant.group) +# +# We reuse invalidate_for_scope_delta(), which resolves the full set of Devices/VMs reachable via +# the given scope dimension (descendants included for nested/ltree models), exactly matching the objects +# whose FK is about to be nulled. + +def _make_upstream_delete_handler(scope_field): + def _handler(sender, instance, using=None, **kwargs): + invalidate_for_scope_delta(scope_field, [instance.pk], using=using) + + return _handler + + +def _connect_upstream_delete_handlers(): + from django.apps import apps as django_apps + + # (app, model, scope_field) + delete_triggers = ( + ('dcim', 'Platform', 'platforms'), + ('dcim', 'Region', 'regions'), + ('dcim', 'SiteGroup', 'site_groups'), + ('virtualization', 'Cluster', 'clusters'), + ('tenancy', 'TenantGroup', 'tenant_groups'), + ) + for app, name, scope_field in delete_triggers: + Model = django_apps.get_model(app, name) + pre_delete.connect( + _make_upstream_delete_handler(scope_field), + sender=Model, + weak=False, + ) + + +_connect_upstream_delete_handlers() + + # # Event rules # diff --git a/netbox/extras/tables/columns.py b/netbox/extras/tables/columns.py index 9b6aadcbf..a17767507 100644 --- a/netbox/extras/tables/columns.py +++ b/netbox/extras/tables/columns.py @@ -1,12 +1,40 @@ +import django_tables2 as tables +from django.utils.html import format_html from django.utils.translation import gettext as _ +from extras.choices import CustomFieldStatusChoices from netbox.tables.columns import ActionsColumn, ActionsItem __all__ = ( + 'CustomFieldStatusColumn', 'NotificationActionsColumn', ) +class CustomFieldStatusColumn(tables.Column): + """ + Render a custom field's status as an icon: a checkmark where the field is live, and a warning + where a bulk update of its stored data is still pending (see CustomFieldStatusChoices). + + An icon because the status is worth noting only in the exceptional case, which is any state + other than active. The full label is given as hover text, and is what an export records. + """ + ICONS = { + True: ('text-bg-green', 'mdi-check-bold'), + False: ('text-bg-orange', 'mdi-alert'), + } + + def render(self, record): + css_class, icon = self.ICONS[record.status == CustomFieldStatusChoices.STATUS_ACTIVE] + return format_html( + '', + css_class, record.get_status_display(), icon + ) + + def value(self, record): + return record.get_status_display() + + class NotificationActionsColumn(ActionsColumn): actions = { 'dismiss': ActionsItem(_('Dismiss'), 'trash-can-outline', 'delete', 'danger'), diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index fac030061..1816c9a49 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -12,7 +12,7 @@ from netbox.constants import EMPTY_TABLE_TEXT from netbox.events import get_event_text from netbox.tables import BaseTable, NetBoxTable, PrimaryModelTable, columns -from .columns import NotificationActionsColumn +from .columns import CustomFieldStatusColumn, NotificationActionsColumn __all__ = ( 'BookmarkTable', @@ -87,6 +87,9 @@ class CustomFieldTable(NetBoxTable): verbose_name=_('Validate Uniqueness'), false_mark=None ) + status = CustomFieldStatusColumn( + verbose_name=_('Status') + ) ui_visible = columns.ChoiceFieldColumn( verbose_name=_('Visible') ) @@ -112,6 +115,10 @@ class CustomFieldTable(NetBoxTable): verbose_name=_('Is Cloneable'), false_mark=None ) + nulls_first = columns.BooleanColumn( + verbose_name=_('Nulls First'), + false_mark=None + ) validation_minimum = tables.Column( verbose_name=_('Minimum Value'), ) @@ -135,11 +142,13 @@ class CustomFieldTable(NetBoxTable): fields = ( 'pk', 'id', 'name', 'object_types', 'label', 'type', 'related_object_type', 'group_name', 'required', 'unique', 'default', 'description', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', - 'is_cloneable', 'weight', 'choice_set', 'choices', 'validation_minimum', 'validation_maximum', - 'validation_regex', 'validation_schema', 'comments', 'created', 'last_updated', + 'is_cloneable', 'nulls_first', 'weight', 'choice_set', 'choices', 'validation_minimum', + 'validation_maximum', 'validation_regex', 'validation_schema', 'status', 'comments', 'created', + 'last_updated', ) default_columns = ( - 'pk', 'name', 'object_types', 'label', 'group_name', 'type', 'required', 'unique', 'description', + 'pk', 'name', 'status', 'object_types', 'label', 'group_name', 'type', 'required', 'unique', + 'description', ) @@ -487,6 +496,9 @@ class WebhookTable(NetBoxTable): ssl_verification = columns.BooleanColumn( verbose_name=_('SSL Verification'), ) + timeout = tables.Column( + verbose_name=_('Timeout (sec)'), + ) owner = tables.Column( linkify=True, verbose_name=_('Owner') @@ -499,7 +511,7 @@ class WebhookTable(NetBoxTable): model = Webhook fields = ( 'pk', 'id', 'name', 'http_method', 'payload_url', 'http_content_type', 'secret', 'ssl_verification', - 'ca_file_path', 'description', 'tags', 'created', 'last_updated', + 'ca_file_path', 'timeout', 'description', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'http_method', 'payload_url', 'description', @@ -548,6 +560,19 @@ class EventRuleTable(NetBoxTable): 'pk', 'name', 'enabled', 'action_type', 'action_object', 'object_types', 'event_types', ) + def render_action_type(self, record): + # Render explicitly (rather than relying on django-tables2's built-in choices-driven + # get_FOO_display() auto-rendering) so an unavailable action type gets a red badge. + label = record.get_action_type_display() + if not record.action_is_available: + return format_html('{}', label) + return label + + def value_action_type(self, record): + # Raw value for non-HTML output (e.g. CSV/table-config export), so the badge's HTML + # markup from render_action_type() above isn't leaked into it. + return record.get_action_type_display() + class TagTable(NetBoxTable): name = tables.Column( diff --git a/netbox/extras/templatetags/custom_links.py b/netbox/extras/templatetags/custom_links.py index 6142879f3..27a4f61e2 100644 --- a/netbox/extras/templatetags/custom_links.py +++ b/netbox/extras/templatetags/custom_links.py @@ -5,6 +5,7 @@ from django.utils.safestring import mark_safe from core.models import ObjectType from extras.models import CustomLink from netbox.choices import ButtonColorChoices +from utilities.request import get_safe_request_context register = template.Library() @@ -43,7 +44,7 @@ def custom_links(context, obj): link_context = { 'object': obj, 'debug': context.get('debug', False), # django.template.context_processors.debug - 'request': context['request'], # django.template.context_processors.request + 'request': get_safe_request_context(context['request']), # Sanitized subset of the request 'user': context['user'], # django.contrib.auth.context_processors.auth 'perms': context['perms'], # django.contrib.auth.context_processors.auth } diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 93bee19b4..7e3e59b85 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -17,10 +17,13 @@ from core.choices import ManagedFileRootPathChoices from core.events import * from core.models import DataFile, DataSource, Job, ObjectType from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Rack, RackRole, Site +from extras.api.serializers import EventRuleSerializer from extras.choices import * from extras.models import * from extras.scripts import BooleanVar, IntegerVar, StringVar from extras.scripts import Script as PythonClass +from netbox.event_rules import EventRuleAction, register_event_rule_action +from netbox.registry import registry from users.constants import TOKEN_PREFIX from users.models import Group, ObjectPermission, Token, User from utilities.tables import get_table_for_model @@ -44,6 +47,7 @@ class WebhookTestCase(APIViewTestCases.APIViewTestCase): { 'name': 'Webhook 4', 'payload_url': 'http://example.com/?4', + 'timeout': 15, }, { 'name': 'Webhook 5', @@ -157,6 +161,129 @@ class EventRuleTestCase(APIViewTestCases.APIViewTestCase): ] +class EventRuleActionAPITestCase(APITestCase): + """ + REST API tests for EventRule's registry-driven action_type. + """ + + def test_create_event_rule_with_unregistered_action_type_fails(self): + self.add_permissions('extras.add_eventrule') + url = reverse('extras-api:eventrule-list') + data = { + 'name': 'API Bad Action Rule', + 'object_types': ['dcim.site'], + 'event_types': [OBJECT_CREATED], + 'action_type': 'this.is.not.registered', + } + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertIn('action_type', response.data) + + def test_update_unrelated_field_on_unavailable_action_rule_fails(self): + """PATCHing a rule with an unavailable action_type is rejected, even for an unrelated field.""" + rule = EventRule.objects.create( + name='API Unavailable Rule', + event_types=[OBJECT_CREATED], + action_type='some.plugin.not_installed', + ) + rule.object_types.set([ObjectType.objects.get_for_model(Site)]) + + self.add_permissions('extras.change_eventrule') + url = reverse('extras-api:eventrule-detail', kwargs={'pk': rule.pk}) + response = self.client.patch(url, {'enabled': False}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertIn('action_type', response.data) + + rule.refresh_from_db() + self.assertTrue(rule.enabled) + + def test_action_is_available_exposed_via_api(self): + """action_is_available is exposed as a read-only field, so unavailable rules can be found in bulk.""" + available_rule = EventRule.objects.create( + name='API Available Rule', event_types=[OBJECT_CREATED], action_type='webhook', + ) + available_rule.object_types.set([ObjectType.objects.get_for_model(Site)]) + + unavailable_rule = EventRule.objects.create( + name='API Unavailable Flag Rule', + event_types=[OBJECT_CREATED], + action_type='some.plugin.not_installed_flag_test', + ) + unavailable_rule.object_types.set([ObjectType.objects.get_for_model(Site)]) + + self.add_permissions('extras.view_eventrule') + url = reverse('extras-api:eventrule-detail', kwargs={'pk': available_rule.pk}) + response = self.client.get(url, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertTrue(response.data['action_is_available']) + + url = reverse('extras-api:eventrule-detail', kwargs={'pk': unavailable_rule.pk}) + response = self.client.get(url, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertFalse(response.data['action_is_available']) + + def test_create_event_rule_with_runtime_registered_action(self): + """An action registered after this serializer's module was imported must still be a valid action_type.""" + class NoObjectAction(EventRuleAction): + slug = 'test.api_no_object_action' + label = 'API No-Object Action' + + register_event_rule_action(NoObjectAction) + self.addCleanup(registry['event_rule_actions'].pop, NoObjectAction.slug, None) + + self.add_permissions('extras.add_eventrule') + url = reverse('extras-api:eventrule-list') + data = { + 'name': 'API No-Object Rule', + 'object_types': ['dcim.site'], + 'event_types': [OBJECT_CREATED], + 'action_type': NoObjectAction.slug, + } + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + + rule = EventRule.objects.get(pk=response.data['id']) + self.assertEqual(rule.action_type, NoObjectAction.slug) + self.assertIsNone(rule.action_object_type) + self.assertIsNone(rule.action_object_id) + + def test_create_event_rule_with_object_for_no_object_action_fails(self): + """An action with no object_model must reject a target object rather than storing it.""" + class NoObjectAction(EventRuleAction): + slug = 'test.api_no_object_action' + label = 'API No-Object Action' + + register_event_rule_action(NoObjectAction) + self.addCleanup(registry['event_rule_actions'].pop, NoObjectAction.slug, None) + + site = Site.objects.create(name='Action Object Site', slug='action-object-site') + + self.add_permissions('extras.add_eventrule') + url = reverse('extras-api:eventrule-list') + data = { + 'name': 'API Bogus Object Rule', + 'object_types': ['dcim.site'], + 'event_types': [OBJECT_CREATED], + 'action_type': NoObjectAction.slug, + 'action_object_type': 'dcim.site', + 'action_object_id': site.pk, + } + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertIn('action_object_id', response.data) + self.assertFalse(EventRule.objects.filter(name='API Bogus Object Rule').exists()) + + def test_action_object_type_field_accepts_any_content_type(self): + """action_object_type's queryset must not be restricted to the with_feature('event_rules') set.""" + field = EventRuleSerializer().fields['action_object_type'] + user_ct = ObjectType.objects.get_for_model(User) + self.assertFalse( + ObjectType.objects.with_feature('event_rules').filter(pk=user_ct.pk).exists(), + "auth.user must not support event_rules for this test to be meaningful; pick another type.", + ) + self.assertIn(user_ct, field.queryset) + + class CustomFieldTestCase(APIViewTestCases.APIViewTestCase): model = CustomField brief_fields = ['description', 'display', 'id', 'name', 'url'] @@ -179,6 +306,7 @@ class CustomFieldTestCase(APIViewTestCases.APIViewTestCase): ] bulk_update_data = { 'description': 'New description', + 'nulls_first': False, } update_data = { 'object_types': ['dcim.device'], @@ -197,7 +325,8 @@ class CustomFieldTestCase(APIViewTestCases.APIViewTestCase): ), CustomField( name='cf2', - type='integer' + type='integer', + nulls_first=False ), CustomField( name='cf3', @@ -1564,6 +1693,19 @@ class ScriptTestCase(APITestCase): response = self.client.post(self.url, payload, format='json', **ro_header) self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN) + def test_run_script_session_auth(self): + """ + The token write-ability check applies only to token authentication. Session-authenticated requests + (where request.auth is not a Token) must still be permitted to run scripts. + """ + self.add_permissions('extras.run_script') + payload = {'data': {'var1': 'hello', 'var2': 1, 'var3': False}, 'commit': True} + + # Authenticate via session rather than a token; request.auth is None + self.client.force_authenticate(user=self.user) + response = self.client.post(self.url, payload, format='json') + self.assertHttpStatus(response, status.HTTP_200_OK) + def test_run_script_not_executable(self): """ A script whose Python class cannot be resolved must be rejected, not raise an exception. diff --git a/netbox/extras/tests/test_conditions.py b/netbox/extras/tests/test_conditions.py index 53b3c6ac3..d7135ff63 100644 --- a/netbox/extras/tests/test_conditions.py +++ b/netbox/extras/tests/test_conditions.py @@ -4,7 +4,7 @@ from django.test import TestCase from core.events import * from dcim.choices import SiteStatusChoices from dcim.models import Site -from extras.conditions import Condition, ConditionSet, InvalidCondition +from extras.conditions import AbsentData, Condition, ConditionSet, InvalidCondition from extras.events import serialize_for_event from extras.forms import EventRuleForm from extras.models import EventRule, Webhook @@ -53,6 +53,24 @@ class ConditionTestCase(TestCase): with self.assertRaises(InvalidCondition): c.eval({'x': {'y': {'a': 1}}}) + def test_nested_within_list(self): + c = Condition('tags.slug', 'exempt', 'contains') + self.assertTrue(c.eval({'tags': [{'slug': 'exempt'}, {'slug': 'other'}]})) + self.assertFalse(c.eval({'tags': [{'slug': 'other'}]})) + + def test_nested_within_empty_list(self): + """ + Descending into an empty list resolves to an empty list, not an absent attribute: an + object with no tags is a legitimate non-match for the documented 'tags.slug contains' + condition, not a malformed path. Raising here would abort the whole condition set, + which for an 'or' set means a matching sibling condition never gets evaluated. + """ + self.assertFalse(Condition('tags.slug', 'exempt', 'contains').eval({'tags': []})) + self.assertTrue(Condition('tags.slug', 'exempt', 'contains', negate=True).eval({'tags': []})) + self.assertTrue(Condition('tags.slug', [], 'eq').eval({'tags': []})) + # The list is carried through the remainder of the path, just as a populated one is + self.assertFalse(Condition('tags.parent.slug', 'exempt', 'contains').eval({'tags': []})) + # # Operator tests # @@ -236,6 +254,28 @@ class ConditionSetTestCase(TestCase): self.assertFalse(cs.eval({'a': 9, 'b': 2, 'c': 9})) self.assertFalse(cs.eval({'a': 9, 'b': 9, 'c': 3})) + def test_untagged_object_does_not_veto_sibling_conditions(self): + """ + The documented "status is active and primary_ip4 is defined, or the exempt tag is + applied" example, evaluated for an object with no tags at all. The tag condition is a + plain non-match: it must not abort the set before its sibling is reached, whichever + order the two are listed in. + """ + tag_rule = {'attr': 'tags.slug', 'value': 'exempt', 'op': 'contains'} + status_rule = {'and': [ + {'attr': 'status.value', 'value': 'active'}, + {'attr': 'primary_ip4', 'value': None, 'negate': True}, + ]} + data = {'status': {'value': 'active'}, 'primary_ip4': {'address': '192.0.2.1/32'}, 'tags': []} + + self.assertTrue(ConditionSet({'or': [tag_rule, status_rule]}).eval(data)) + self.assertTrue(ConditionSet({'or': [status_rule, tag_rule]}).eval(data)) + + # Neither condition matches: still False rather than an error + self.assertFalse(ConditionSet({'or': [tag_rule, status_rule]}).eval({ + 'status': {'value': 'planned'}, 'primary_ip4': None, 'tags': [] + })) + def test_event_rule_conditions_without_logic_operator(self): """ Test evaluation of EventRule conditions without logic operator. @@ -321,3 +361,668 @@ class ConditionSetTestCase(TestCase): }) self.assertFalse(form.is_valid()) + + +class AbsentDataTestCase(TestCase): + """ + Tests for conditions evaluated against an AbsentData payload, i.e. event data which is + absent altogether (a job which recorded no data) rather than merely lacking the + referenced attribute. + """ + + def _absent_data(self, **kwargs): + """Return an absent payload as produced by process_event_rules().""" + data = AbsentData() + data['snapshots'] = kwargs.get('snapshots') + return data + + def test_absent_data_is_a_non_match(self): + """ + An absent payload carries nothing to match against, so a reference to any attribute of + it is a non-match rather than a resolved null. Matching would enqueue the rule's action + on an event which recorded no data at all. + """ + data = self._absent_data() + self.assertFalse(Condition('status', value=None).eval(data)) + self.assertFalse(Condition('status', value='completed').eval(data)) + # A nested path is equally unresolvable, and equally not an error + self.assertFalse(Condition('output.result', value='x').eval(data)) + self.assertFalse(Condition('output.result', value=None).eval(data)) + + def test_absent_data_is_a_non_match_for_every_operator(self): + data = self._absent_data() + for op, value in ( + ('eq', None), ('eq', 'foo'), ('in', ['foo']), ('contains', 'foo'), ('regex', '^foo'), + ('gt', 1), ('gte', 1), ('lt', 1), ('lte', 1), + ): + with self.subTest(op=op, value=value): + self.assertFalse(Condition('status', value=value, op=op).eval(data)) + + def test_negate_cannot_turn_an_absent_payload_into_a_match(self): + """ + Negation inverts the result of a comparison, and against an absent payload no + comparison takes place. Inverting the non-match would make 'negate' a fail-open switch, + firing the rule on an empty payload - for a misspelled attribute as readily as a real + one, since neither resolves. + """ + data = self._absent_data() + for attr in ('status', 'stauts', 'output.result'): + for value in (None, 'completed'): + with self.subTest(attr=attr, value=value): + self.assertFalse(Condition(attr, value=value, negate=True).eval(data)) + self.assertFalse(Condition('status', value='foo', op='contains', negate=True).eval(data)) + + def test_absent_data_does_not_veto_sibling_conditions(self): + """ + An absent payload must not abort evaluation of the whole condition set: the other + conditions, including a snapshot path drawn from the surrounding context, are still + evaluated on their own merits. + """ + data = self._absent_data(snapshots={'prechange': {'status': 'planned'}, 'postchange': None}) + ruleset = {'or': [ + {'attr': 'status', 'value': 'foo', 'op': 'regex'}, + {'attr': 'snapshots.prechange.status', 'value': 'planned'}, + ]} + self.assertTrue(ConditionSet(ruleset).eval(data)) + self.assertFalse(ConditionSet({'and': list(ruleset['or'])}).eval(data)) + + def test_present_data_missing_attr_still_fails_closed(self): + """ + Only data which is absent altogether resolves to None; a payload which is present + but lacks the attribute remains a fail-closed error, so that a typo is logged. + """ + with self.assertRaises(InvalidCondition): + Condition('status', value='completed').eval({'other': 1}) + + def test_snapshot_path_against_absent_data(self): + """ + The absent-payload marker must not short-circuit a snapshot path: the snapshots key + is part of the evaluation context, not of the payload. + """ + data = self._absent_data(snapshots={'prechange': {'status': 'planned'}, 'postchange': None}) + self.assertTrue(Condition('snapshots.prechange.status', value='planned').eval(data)) + self.assertTrue(Condition('snapshots.postchange.status', value=None).eval(data)) + + +class SnapshotConditionTestCase(TestCase): + """ + Tests for snapshot-aware conditions: the 'changed'/'unchanged' operators and + direct snapshot attribute access via the snapshots.prechange.* / snapshots.postchange.* + dot-path syntax. + """ + + def _make_condition_data(self, site, snapshots): + """Return a condition evaluation context as produced by process_event_rules().""" + return {**serialize_for_event(site), 'snapshots': snapshots} + + # + # Validation + # + + def test_changed_operator_rejects_value(self): + with self.assertRaises(ValueError): + Condition('status', value='active', op='changed') + + def test_unchanged_operator_rejects_value(self): + with self.assertRaises(ValueError): + Condition('status', value='active', op='unchanged') + + def test_snapshot_operator_rejects_snapshot_path_attr(self): + """Snapshot operators must not use a snapshots.prechange.* path — that's only for standard operators.""" + with self.assertRaises(ValueError): + Condition('snapshots.prechange.status', op='changed') + with self.assertRaises(ValueError): + Condition('snapshots.postchange.status', op='unchanged') + + def test_standard_operator_requires_value(self): + with self.assertRaises(ValueError): + Condition('status', op='eq') + + # + # 'changed' operator + # + + def test_changed_true_when_attr_differs(self): + c = Condition('status', op='changed') + snapshots = { + 'prechange': {'status': 'planned'}, + 'postchange': {'status': 'active'}, + } + self.assertTrue(c.eval({'snapshots': snapshots})) + + def test_changed_false_when_attr_same(self): + c = Condition('status', op='changed') + snapshots = { + 'prechange': {'status': 'active'}, + 'postchange': {'status': 'active'}, + } + self.assertFalse(c.eval({'snapshots': snapshots})) + + def test_changed_true_when_prechange_missing_attr(self): + # attr present in postchange but absent from prechange snapshot + c = Condition('description', op='changed') + snapshots = { + 'prechange': {}, + 'postchange': {'description': 'hello'}, + } + self.assertTrue(c.eval({'snapshots': snapshots})) + + def test_changed_true_when_prechange_is_none(self): + # OBJECT_CREATED events have no prechange snapshot + c = Condition('status', op='changed') + snapshots = { + 'prechange': None, + 'postchange': {'status': 'active'}, + } + self.assertTrue(c.eval({'snapshots': snapshots})) + + def test_changed_raises_when_both_snapshots_missing_attr(self): + # An attr absent from both snapshots leaves nothing to compare: report it rather than + # returning a non-match, which negate would turn into a match + c = Condition('nonexistent', op='changed') + snapshots = { + 'prechange': {'status': 'active'}, + 'postchange': {'status': 'active'}, + } + with self.assertRaises(InvalidCondition): + c.eval({'snapshots': snapshots}) + + def test_changed_raises_when_path_traverses_scalar(self): + # Snapshot choice fields are raw strings, not nested dicts. A REST API-style path + # like 'status.value' cannot be walked at all, which is a malformed condition + # rather than an absent attribute: it must raise so that the mistake is logged, + # not silently evaluate False on every event. + c = Condition('status.value', op='changed') + snapshots = { + 'prechange': {'status': 'planned'}, + 'postchange': {'status': 'active'}, + } + with self.assertRaises(InvalidCondition): + c.eval({'snapshots': snapshots}) + + def test_changed_raises_when_path_traverses_falsy_scalar(self): + # Walkability is a property of the value's type, not its truthiness: an empty string + # is exactly as unwalkable as a populated one, and must not be mistaken for an absent + # attribute. (description and comments default to an empty string on most models, so + # this is the common case rather than an edge case.) + c = Condition('description.value', op='changed') + snapshots = { + 'prechange': {'description': ''}, + 'postchange': {'description': 'foo'}, + } + with self.assertRaises(InvalidCondition): + c.eval({'snapshots': snapshots}) + + def test_unchanged_raises_when_path_traverses_scalar(self): + c = Condition('status.value', op='unchanged') + snapshots = { + 'prechange': {'status': 'active'}, + 'postchange': {'status': 'active'}, + } + with self.assertRaises(InvalidCondition): + c.eval({'snapshots': snapshots}) + + def test_changed_raises_when_path_traverses_scalar_in_list(self): + # Snapshot list fields hold raw values (e.g. tag names), so a path descending + # into a list element is equally unwalkable. + c = Condition('tags.name', op='changed') + snapshots = { + 'prechange': {'tags': ['Alpha']}, + 'postchange': {'tags': ['Alpha', 'Beta']}, + } + with self.assertRaises(InvalidCondition): + c.eval({'snapshots': snapshots}) + + def test_changed_raises_when_counterpart_snapshot_resolves_to_nothing(self): + """ + Only a snapshot which actually yields a value excuses an unwalkable path in the + other. A snapshot which merely resolves to nothing - an empty list, an absent key - + is no evidence that the path is well-formed, so the malformed condition must still + be reported rather than evaluating (and possibly firing) until the data fills in. + """ + for snapshots in ( + # Tagging a previously untagged object: the likely first evaluation of the rule + {'prechange': {'tags': []}, 'postchange': {'tags': ['Alpha']}}, + {'prechange': {'tags': ['Alpha']}, 'postchange': {'tags': []}}, + ): + with self.subTest(snapshots=snapshots): + with self.assertRaises(InvalidCondition): + Condition('tags.name', op='changed').eval({'snapshots': snapshots}) + + with self.assertRaises(InvalidCondition): + Condition('status.value', op='changed').eval({ + 'snapshots': {'prechange': {}, 'postchange': {'status': 'active'}} + }) + + def test_changed_when_path_is_walkable_in_only_one_snapshot(self): + """ + A path which resolves in one snapshot but not the other is not malformed: it + describes a real difference between them, such as a JSON attribute whose value + changed shape. The unwalkable side counts as missing and the comparison proceeds, + rather than raising and reporting the attribute as unchanged. + """ + snapshots = { + 'prechange': {'custom_fields': {'blob': 'legacy'}}, + 'postchange': {'custom_fields': {'blob': {'key': 1}}}, + } + reversed_snapshots = {'prechange': snapshots['postchange'], 'postchange': snapshots['prechange']} + self.assertTrue(Condition('custom_fields.blob.key', op='changed').eval({'snapshots': snapshots})) + self.assertTrue(Condition('custom_fields.blob.key', op='changed').eval({'snapshots': reversed_snapshots})) + self.assertFalse(Condition('custom_fields.blob.key', op='unchanged').eval({'snapshots': snapshots})) + + def test_changed_raises_when_only_available_snapshot_traverses_scalar(self): + """ + On create and delete events only one snapshot is available, so an unwalkable path is + unwalkable everywhere it can be evaluated: still malformed, and still raises. + """ + with self.assertRaises(InvalidCondition): + Condition('status.value', op='changed').eval({ + 'snapshots': {'prechange': None, 'postchange': {'status': 'active'}} + }) + with self.assertRaises(InvalidCondition): + Condition('status.value', op='changed').eval({ + 'snapshots': {'prechange': {'status': 'active'}, 'postchange': None} + }) + + def test_changed_raises_when_no_snapshot_is_available(self): + """ + With neither snapshot available there is nothing to compare, whether the path is + malformed or not. Reporting the condition is the only way to fail closed: a boolean + would be a verdict on data the event never carried, and negate would turn it into a + match. + """ + snapshots = {'prechange': None, 'postchange': None} + for attr, op, negate in ( + ('status', 'changed', False), + ('status', 'changed', True), + ('status', 'unchanged', True), + ('status.value', 'changed', False), + ('status.value', 'unchanged', False), + ): + with self.subTest(attr=attr, op=op, negate=negate): + with self.assertRaises(InvalidCondition): + Condition(attr, op=op, negate=negate).eval({'snapshots': snapshots}) + + def test_changed_raises_when_attr_resolves_in_neither_snapshot(self): + # An attribute absent from both snapshots is indistinguishable from a typo, so it + # cannot be reported as an ordinary non-match + c = Condition('nonexistent', op='changed') + snapshots = { + 'prechange': {'status': 'planned'}, + 'postchange': {'status': 'active', 'description': 'x'}, + } + with self.assertRaises(InvalidCondition): + c.eval({'snapshots': snapshots}) + + def test_changed_negated(self): + c = Condition('status', op='changed', negate=True) + snapshots = { + 'prechange': {'status': 'planned'}, + 'postchange': {'status': 'active'}, + } + self.assertFalse(c.eval({'snapshots': snapshots})) + self.assertTrue(c.eval({'snapshots': { + 'prechange': {'status': 'active'}, + 'postchange': {'status': 'active'}, + }})) + + def test_negate_cannot_turn_an_unresolved_attr_into_a_match(self): + """ + A misspelled attribute must not fire the rule, whichever operator it is used with and + whether or not the condition is negated. Returning False for the unresolved state + would make 'negate' a fail-open switch. + """ + snapshots = {'prechange': {'status': 'planned'}, 'postchange': {'status': 'active'}} + for op in ('changed', 'unchanged'): + for negate in (False, True): + with self.subTest(op=op, negate=negate): + with self.assertRaises(InvalidCondition): + Condition('statsu', op=op, negate=negate).eval({'snapshots': snapshots}) + + def test_changed_raises_when_no_snapshots(self): + c = Condition('status', op='changed') + with self.assertRaises(InvalidCondition): + c.eval({'status': {'value': 'active'}}) + + def test_changed_raises_when_snapshots_is_not_a_dict(self): + """ + A snapshots value which is not a dict holds no snapshot to compare. It must be reported + as an invalid condition, matching a direct snapshot path against the same data, rather + than raising an uncaught AttributeError which would abort event processing entirely. + """ + for snapshots in ('oops', ['prechange'], 42): + with self.subTest(snapshots=snapshots): + with self.assertRaises(InvalidCondition): + Condition('status', op='changed').eval({'snapshots': snapshots}) + with self.assertRaises(InvalidCondition): + Condition('snapshots.prechange.status', value='active').eval({'snapshots': snapshots}) + + # + # 'unchanged' operator + # + + def test_unchanged_true_when_attr_same(self): + c = Condition('status', op='unchanged') + snapshots = { + 'prechange': {'status': 'active'}, + 'postchange': {'status': 'active'}, + } + self.assertTrue(c.eval({'snapshots': snapshots})) + + def test_unchanged_false_when_attr_differs(self): + c = Condition('status', op='unchanged') + snapshots = { + 'prechange': {'status': 'planned'}, + 'postchange': {'status': 'active'}, + } + self.assertFalse(c.eval({'snapshots': snapshots})) + + def test_unchanged_raises_when_both_snapshots_missing_attr(self): + # Fail-closed: a typo or non-existent attr resolves on neither side, so 'unchanged' + # must report it rather than silently passing (or, negated, matching) + c = Condition('statsu', op='unchanged') + snapshots = { + 'prechange': {'status': 'active'}, + 'postchange': {'status': 'active'}, + } + with self.assertRaises(InvalidCondition): + c.eval({'snapshots': snapshots}) + + # + # Direct snapshot path access (snapshots.prechange.* / snapshots.postchange.*) + # + + def test_snapshot_path_access_prechange(self): + c = Condition('snapshots.prechange.status', value='planned', op='eq') + snapshots = { + 'prechange': {'status': 'planned'}, + 'postchange': {'status': 'active'}, + } + self.assertTrue(c.eval({'snapshots': snapshots})) + + def test_snapshot_path_access_postchange(self): + c = Condition('snapshots.postchange.status', value='active', op='eq') + snapshots = { + 'prechange': {'status': 'planned'}, + 'postchange': {'status': 'active'}, + } + self.assertTrue(c.eval({'snapshots': snapshots})) + + def test_snapshot_path_rest_api_style_attr_raises_invalid_condition(self): + """ + Snapshots store raw values (e.g. status="planned"), not REST API-style nested + dicts (status={"value": "planned"}). A '.value' suffix on a snapshot path must + fail closed with InvalidCondition rather than raising a raw TypeError. + """ + c = Condition('snapshots.prechange.status.value', value='planned', op='eq') + snapshots = { + 'prechange': {'status': 'planned'}, + 'postchange': {'status': 'active'}, + } + with self.assertRaises(InvalidCondition): + c.eval({'snapshots': snapshots}) + + def test_snapshot_path_resolves_to_none_when_prechange_absent(self): + """ + On a create event there is no prechange snapshot. That is a property of the event, + not a malformed condition, so the path resolves to None rather than raising. + """ + snapshots = {'prechange': None, 'postchange': {'status': 'active'}} + self.assertFalse(Condition('snapshots.prechange.status', value='planned').eval({'snapshots': snapshots})) + self.assertTrue(Condition('snapshots.prechange.status', value=None).eval({'snapshots': snapshots})) + self.assertTrue( + Condition('snapshots.prechange.status', value='planned', negate=True).eval({'snapshots': snapshots}) + ) + + def test_snapshot_path_resolves_to_none_when_postchange_absent(self): + """As above, for the postchange snapshot on a delete event.""" + snapshots = {'prechange': {'status': 'active'}, 'postchange': None} + self.assertFalse(Condition('snapshots.postchange.status', value='active').eval({'snapshots': snapshots})) + self.assertTrue(Condition('snapshots.postchange.status', value=None).eval({'snapshots': snapshots})) + + def test_absent_snapshot_is_a_non_match_for_every_operator(self): + """ + An absent snapshot resolves to None, which satisfies only a comparison against null. + Operators which raise a TypeError on None must report a plain non-match rather than + aborting evaluation, so that the guarantee holds for all operators and not just + those which happen to tolerate None. + """ + data = {'snapshots': {'prechange': None, 'postchange': {'description': 'foo'}}} + attr = 'snapshots.prechange.description' + for op, value in (('contains', 'foo'), ('regex', '^foo'), ('gt', 1), ('gte', 1), ('lt', 1), ('lte', 1)): + with self.subTest(op=op): + self.assertFalse(Condition(attr, value=value, op=op).eval(data)) + self.assertTrue(Condition(attr, value=value, op=op, negate=True).eval(data)) + + def test_absent_snapshot_does_not_veto_sibling_conditions(self): + """ + Regression: an absent prechange snapshot must not abort evaluation of the whole + condition set, which would suppress a sibling condition that does match. The + result must also not depend on the order of the conditions, nor on which operator + the snapshot condition uses. + """ + data = {'name': 'Site 1', 'snapshots': {'prechange': None, 'postchange': {'status': 'active'}}} + name_rule = {'attr': 'name', 'value': 'Site 1'} + for snapshot_rule in ( + {'attr': 'snapshots.prechange.status', 'value': 'planned'}, + {'attr': 'snapshots.prechange.status', 'value': 'plan', 'op': 'contains'}, + {'attr': 'snapshots.prechange.status', 'value': '^plan', 'op': 'regex'}, + ): + with self.subTest(op=snapshot_rule.get('op', 'eq')): + self.assertTrue(ConditionSet({'or': [snapshot_rule, name_rule]}).eval(data)) + self.assertTrue(ConditionSet({'or': [name_rule, snapshot_rule]}).eval(data)) + + def test_snapshot_path_typo_still_fails_closed(self): + """ + A path naming a snapshot that exists but lacks the attribute is a genuine typo and + must still raise, so that it is logged rather than silently evaluating. + """ + snapshots = {'prechange': {'status': 'planned'}, 'postchange': {'status': 'active'}} + with self.assertRaises(InvalidCondition): + Condition('snapshots.prechange.stauts', value='planned').eval({'snapshots': snapshots}) + with self.assertRaises(InvalidCondition): + Condition('snapshots.bogus.status', value='planned').eval({'snapshots': snapshots}) + + # A misnamed snapshot which happens to be null names no snapshot the event could have + # recorded, so it has no absence to excuse it: it must fail closed like any other typo, + # rather than resolving to null and firing the rule + with self.assertRaises(InvalidCondition): + Condition('snapshots.bogus.status', value=None).eval({'snapshots': {'bogus': None}}) + + def test_absent_snapshot_path_traversing_scalar_still_fails_closed(self): + """ + An absent snapshot excuses only the absence of the data, not a path which cannot be + walked at all. A REST API-style 'status.value' must fail closed on create and delete + events exactly as it does on updates, rather than resolving to null (which would fire + the rule on every create) with nothing logged. + """ + create = {'snapshots': {'prechange': None, 'postchange': {'status': 'active', 'tags': ['Alpha']}}} + with self.assertRaises(InvalidCondition): + Condition('snapshots.prechange.status.value', value='active').eval(create) + with self.assertRaises(InvalidCondition): + # A test for null must not escape the check either + Condition('snapshots.prechange.status.value', value=None).eval(create) + with self.assertRaises(InvalidCondition): + # Snapshot list fields hold raw values, so descending into an element is + # equally unwalkable + Condition('snapshots.prechange.tags.name', value='Alpha').eval(create) + + delete = {'snapshots': {'prechange': {'status': 'active'}, 'postchange': None}} + with self.assertRaises(InvalidCondition): + Condition('snapshots.postchange.status.value', value='active').eval(delete) + + # An empty string is exactly as unwalkable as a populated one, so the check must not + # turn on the truthiness of the value in the opposite snapshot + blank = {'snapshots': {'prechange': None, 'postchange': {'description': ''}}} + with self.assertRaises(InvalidCondition): + Condition('snapshots.prechange.description.value', value=None).eval(blank) + + def test_absent_snapshot_path_unknown_to_opposite_snapshot_fails_closed(self): + """ + An absent snapshot excuses only a path the opposite snapshot shows to describe the + data. A path which resolves to nothing there either is not shown to describe it, so it + must fail closed rather than resolving to null - which would let an unknown path fire + the rule on every create or delete, with nothing logged, even though the same path + raises on an update event. Testing for the absent snapshot itself remains available + as 'snapshots.prechange' with no remainder. + """ + create = {'snapshots': {'prechange': None, 'postchange': {'custom_fields': {'cf1': 'x'}, 'tags': []}}} + with self.assertRaises(InvalidCondition): + Condition('snapshots.prechange.nonexistent.attr', value=None).eval(create) + with self.assertRaises(InvalidCondition): + Condition('snapshots.prechange.custom_fields.cf2', value=None).eval(create) + # An empty list holds no element in which to find 'name', so it cannot show the path + # to describe the data any more than an absent key can + with self.assertRaises(InvalidCondition): + Condition('snapshots.prechange.tags.name', value=None).eval(create) + + delete = {'snapshots': {'prechange': {'status': 'active'}, 'postchange': None}} + with self.assertRaises(InvalidCondition): + Condition('snapshots.postchange.nonexistent.attr', value=None).eval(delete) + + def test_absent_snapshot_path_resolves_to_none_when_shape_is_valid(self): + """ + A nested path which the opposite snapshot resolves is a genuine absence, so it + resolves to null. So is the absent snapshot itself, and a path which cannot be checked + at all because the opposite snapshot is null too: the event then carries no data + anywhere to check against, so treating it as absent is the only alternative to logging + an error for every rule on every such event. + """ + create = {'snapshots': {'prechange': None, 'postchange': {'custom_fields': {'cf1': 'x'}, 'tags': []}}} + self.assertTrue(Condition('snapshots.prechange.custom_fields.cf1', value=None).eval(create)) + self.assertTrue(Condition('snapshots.prechange', value=None).eval(create)) + # A resolved value is a resolved value, whatever its own shape + self.assertTrue(Condition('snapshots.prechange.tags', value=None).eval(create)) + + both_absent = {'snapshots': {'prechange': None, 'postchange': None}} + self.assertTrue(Condition('snapshots.prechange.status.value', value=None).eval(both_absent)) + self.assertTrue(Condition('snapshots.prechange.nonexistent.attr', value=None).eval(both_absent)) + + def test_snapshot_path_without_snapshot_context_fails_closed(self): + """ + A snapshot path used where there is no snapshot context at all (e.g. a job event) + is a mismatched rule and must fail closed rather than resolving to None. + """ + with self.assertRaises(InvalidCondition): + Condition('snapshots.prechange.status', value='planned').eval({'snapshots': None}) + with self.assertRaises(InvalidCondition): + Condition('snapshots.prechange.status', value='planned').eval({'name': 'Site 1'}) + + # + # EventRule.eval_conditions integration + # + + def test_event_rule_changed_operator(self): + """ + Verify the canonical use case: fire only when status changes to active. + """ + event_rule = EventRule( + name='Notify on activation', + event_types=[OBJECT_UPDATED], + conditions={ + 'and': [ + {'attr': 'status.value', 'value': 'active'}, + {'attr': 'status', 'op': 'changed'}, + ] + } + ) + site = Site.objects.create(name='Site 2', slug='site-2', status=SiteStatusChoices.STATUS_ACTIVE) + + # status changed planned → active: should fire + data_changed = self._make_condition_data(site, { + 'prechange': {'status': SiteStatusChoices.STATUS_PLANNED}, + 'postchange': {'status': SiteStatusChoices.STATUS_ACTIVE}, + }) + self.assertTrue(event_rule.eval_conditions(data_changed)) + + # status already active, description updated: should NOT fire + data_unchanged = self._make_condition_data(site, { + 'prechange': {'status': SiteStatusChoices.STATUS_ACTIVE}, + 'postchange': {'status': SiteStatusChoices.STATUS_ACTIVE}, + }) + self.assertFalse(event_rule.eval_conditions(data_unchanged)) + + def test_event_rule_snapshot_path_with_existing_operator(self): + """ + Conditions can reference prechange/postchange data using the standard + snapshots.prechange. dot-path and existing operators. + Note: snapshot values use model serializer format (raw strings, not nested + dicts), so 'status' not 'status.value'. + """ + event_rule = EventRule( + name='Was planned', + event_types=[OBJECT_UPDATED], + conditions={ + 'attr': 'snapshots.prechange.status', + 'value': SiteStatusChoices.STATUS_PLANNED, + } + ) + site = Site.objects.create(name='Site 3', slug='site-3', status=SiteStatusChoices.STATUS_ACTIVE) + data = self._make_condition_data(site, { + 'prechange': {'status': SiteStatusChoices.STATUS_PLANNED}, + 'postchange': {'status': SiteStatusChoices.STATUS_ACTIVE}, + }) + self.assertTrue(event_rule.eval_conditions(data)) + + def test_event_rule_snapshot_path_rest_api_style_attr_must_return_false(self): + """ + An EventRule condition mistakenly using a REST API-style '.value' suffix on a + snapshot path must fail closed (return False) rather than crashing evaluation. + """ + event_rule = EventRule( + name='Was planned (REST-style mistake)', + event_types=[OBJECT_UPDATED], + conditions={ + 'attr': 'snapshots.prechange.status.value', + 'value': SiteStatusChoices.STATUS_PLANNED, + } + ) + site = Site.objects.create(name='Site 4', slug='site-4', status=SiteStatusChoices.STATUS_ACTIVE) + data = self._make_condition_data(site, { + 'prechange': {'status': SiteStatusChoices.STATUS_PLANNED}, + 'postchange': {'status': SiteStatusChoices.STATUS_ACTIVE}, + }) + self.assertFalse(event_rule.eval_conditions(data)) + + def test_event_rule_snapshot_path_rest_api_style_attr_on_create_is_logged(self): + """ + The same mistake must behave identically on a create event, where the prechange + snapshot is absent: fail closed and log, rather than resolving to null and firing + the rule for every object created. + """ + event_rule = EventRule( + name='Was planned (REST-style mistake)', + event_types=[OBJECT_CREATED, OBJECT_UPDATED], + conditions={ + 'attr': 'snapshots.prechange.status.value', + 'value': None, + } + ) + site = Site.objects.create(name='Site 6', slug='site-6', status=SiteStatusChoices.STATUS_ACTIVE) + data = self._make_condition_data(site, { + 'prechange': None, + 'postchange': {'status': SiteStatusChoices.STATUS_ACTIVE}, + }) + with self.assertLogs('netbox.event_rules', level='ERROR') as cm: + self.assertFalse(event_rule.eval_conditions(data)) + self.assertIn('snapshots.prechange.status.value', cm.output[0]) + + def test_event_rule_changed_operator_rest_api_style_attr_is_logged(self): + """ + The same REST API-style mistake made with a snapshot operator must also fail + closed *and* be logged. Silently evaluating False would leave the rule dead with + no indication of why, even though the watched attribute really did change. + """ + event_rule = EventRule( + name='Activated (REST-style mistake)', + event_types=[OBJECT_UPDATED], + conditions={'attr': 'status.value', 'op': 'changed'} + ) + site = Site.objects.create(name='Site 5', slug='site-5', status=SiteStatusChoices.STATUS_ACTIVE) + data = self._make_condition_data(site, { + 'prechange': {'status': SiteStatusChoices.STATUS_PLANNED}, + 'postchange': {'status': SiteStatusChoices.STATUS_ACTIVE}, + }) + with self.assertLogs('netbox.event_rules', level='ERROR') as cm: + self.assertFalse(event_rule.eval_conditions(data)) + self.assertIn('status.value', cm.output[0]) diff --git a/netbox/extras/tests/test_configcontext_cache.py b/netbox/extras/tests/test_configcontext_cache.py new file mode 100644 index 000000000..c4018ea2f --- /dev/null +++ b/netbox/extras/tests/test_configcontext_cache.py @@ -0,0 +1,989 @@ +from unittest import mock + +from django.db import connection, router +from django.db.models import F, Q +from django.test import TestCase, override_settings +from django.test.utils import CaptureQueriesContext + +from core.models import Job +from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup +from extras.cache import ( + invalidate_config_context_for_configcontext, + invalidate_config_context_for_objects, + invalidate_for_scope_delta, +) +from extras.jobs import RenderConfigContextJob +from extras.models import ConfigContext, Tag +from extras.signals import _make_direct_upstream_handler, _make_reparent_handler +from tenancy.models import Tenant, TenantGroup +from utilities.testing import PinnedConnectionRouter +from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine + + +def _set_cache(device, value): + """Manually set the cache field and refresh the in-memory instance so subsequent save() + calls don't overwrite the DB value with stale in-memory state.""" + type(device).objects.filter(pk=device.pk).update(_config_context_data=value) + device.refresh_from_db() + + +def _get_cache(device): + device.refresh_from_db() + return device._config_context_data + + +class ConfigContextCacheReadPathTest(TestCase): + """ + get_config_context() must return the cached `_config_context_data` blob when present, and + fall back to the on-demand render path when it is NULL. + """ + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='mfr') + cls.devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='DT', slug='dt') + cls.role = DeviceRole.objects.create(name='Role', slug='role') + cls.site = Site.objects.create(name='Site', slug='site') + cls.device = Device.objects.create( + name='Device', device_type=cls.devicetype, role=cls.role, site=cls.site + ) + + def test_cached_value_is_returned(self): + cached = {'cached': True, 'value': 42} + _set_cache(self.device, cached) + device = Device.objects.get(pk=self.device.pk) + self.assertEqual(device.get_config_context(), cached) + + def test_null_cache_falls_back_to_render(self): + ConfigContext.objects.create(name='CC', weight=100, data={'rendered': True}) + device = Device.objects.get(pk=self.device.pk) + self.assertIsNone(device._config_context_data) + self.assertEqual(device.get_config_context(), {'rendered': True}) + + def test_render_matches_legacy_path(self): + ConfigContext.objects.create(name='A', weight=100, data={'a': 1}) + ConfigContext.objects.create(name='B', weight=200, data={'a': 2, 'b': 3}) + + device = Device.objects.get(pk=self.device.pk) + on_demand = device.render_config_context() + _set_cache(device, on_demand) + self.assertEqual(device.get_config_context(), on_demand) + + +class ConfigContextInvalidationTest(TestCase): + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Mfr', slug='mfr') + cls.devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='DT', slug='dt') + cls.role = DeviceRole.objects.create(name='Role', slug='role') + cls.role2 = DeviceRole.objects.create(name='Role 2', slug='role-2') + cls.site1 = Site.objects.create(name='Site 1', slug='site-1') + cls.site2 = Site.objects.create(name='Site 2', slug='site-2') + cls.device_in_scope = Device.objects.create( + name='In scope', device_type=cls.devicetype, role=cls.role, site=cls.site1, + ) + cls.device_out_of_scope = Device.objects.create( + name='Out of scope', device_type=cls.devicetype, role=cls.role, site=cls.site2, + ) + + def setUp(self): + # Pre-populate caches for both devices. + _set_cache(self.device_in_scope, {'cached': True}) + _set_cache(self.device_out_of_scope, {'cached': True}) + + def test_configcontext_save_invalidates_in_scope_only(self): + cc = ConfigContext.objects.create(name='CC', weight=100, data={'x': 1}) + cc.sites.add(self.site1) + # Re-populate caches (the create + m2m add above already triggered invalidations). + _set_cache(self.device_in_scope, {'cached': True}) + _set_cache(self.device_out_of_scope, {'cached': True}) + + cc.data = {'x': 2} + cc.save() + + self.assertIsNone(_get_cache(self.device_in_scope)) + self.assertEqual(_get_cache(self.device_out_of_scope), {'cached': True}) + + def test_configcontext_delete_invalidates_in_scope(self): + cc = ConfigContext.objects.create(name='CC', weight=100, data={'x': 1}) + cc.sites.add(self.site1) + _set_cache(self.device_in_scope, {'cached': True}) + _set_cache(self.device_out_of_scope, {'cached': True}) + + cc.delete() + + self.assertIsNone(_get_cache(self.device_in_scope)) + self.assertEqual(_get_cache(self.device_out_of_scope), {'cached': True}) + + def test_m2m_post_add_invalidates_newly_in_scope(self): + cc = ConfigContext.objects.create(name='CC', weight=100, data={'x': 1}) + _set_cache(self.device_in_scope, {'cached': True}) + + cc.sites.add(self.site1) + + self.assertIsNone(_get_cache(self.device_in_scope)) + + def test_m2m_post_remove_invalidates_previously_in_scope(self): + cc = ConfigContext.objects.create(name='CC', weight=100, data={'x': 1}) + cc.sites.add(self.site1) + _set_cache(self.device_in_scope, {'cached': True}) + + cc.sites.remove(self.site1) + + self.assertIsNone(_get_cache(self.device_in_scope)) + + def test_device_role_change_invalidates(self): + self.device_in_scope.snapshot() + self.device_in_scope.role = self.role2 + self.device_in_scope.save() + + self.assertIsNone(_get_cache(self.device_in_scope)) + + def test_device_serial_change_does_not_invalidate(self): + # Refresh first so the in-memory instance has the cached value (avoids save() writing + # stale NULL back to the DB). + self.device_in_scope.refresh_from_db() + self.device_in_scope.snapshot() + self.device_in_scope.serial = 'ABC123' + self.device_in_scope.save() + + self.assertEqual(_get_cache(self.device_in_scope), {'cached': True}) + + def test_device_tag_add_invalidates(self): + tag = Tag.objects.create(name='Tag', slug='tag') + self.device_in_scope.tags.add(tag) + + self.assertIsNone(_get_cache(self.device_in_scope)) + + +class ConfigContextUpstreamDeleteInvalidationTest(TestCase): + """ + Deleting an upstream object referenced by a Device/VM via a SET_NULL FK nulls that FK with a + bulk UPDATE that emits no post_save signal. The pre_delete handlers must invalidate the + affected caches before the references are cleared. + """ + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Mfr', slug='mfr') + cls.devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='DT', slug='dt') + cls.role = DeviceRole.objects.create(name='Role', slug='role') + + def test_platform_delete_invalidates(self): + platform = Platform.objects.create(name='Platform', slug='platform') + site = Site.objects.create(name='Site', slug='site') + device = Device.objects.create( + name='Device', device_type=self.devicetype, role=self.role, site=site, platform=platform, + ) + _set_cache(device, {'cached': True}) + + platform.delete() + + self.assertIsNone(_get_cache(device)) + + def test_region_delete_invalidates(self): + region = Region.objects.create(name='Region', slug='region') + site = Site.objects.create(name='Site', slug='site', region=region) + device = Device.objects.create( + name='Device', device_type=self.devicetype, role=self.role, site=site, + ) + _set_cache(device, {'cached': True}) + + region.delete() + + self.assertIsNone(_get_cache(device)) + + def test_site_group_delete_invalidates(self): + group = SiteGroup.objects.create(name='Group', slug='group') + site = Site.objects.create(name='Site', slug='site', group=group) + device = Device.objects.create( + name='Device', device_type=self.devicetype, role=self.role, site=site, + ) + _set_cache(device, {'cached': True}) + + group.delete() + + self.assertIsNone(_get_cache(device)) + + +class RenderConfigContextJobTest(TestCase): + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Mfr', slug='mfr') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='DT', slug='dt') + role = DeviceRole.objects.create(name='Role', slug='role') + site = Site.objects.create(name='Site', slug='site') + cls.device = Device.objects.create( + name='Device', device_type=devicetype, role=role, site=site, + ) + ConfigContext.objects.create(name='CC', weight=100, data={'foo': 'bar'}) + + def _make_runner(self): + runner = RenderConfigContextJob.__new__(RenderConfigContextJob) + runner.job = mock.Mock() + runner.logger = mock.Mock() + return runner + + def test_job_populates_cache(self): + Device.objects.filter(pk=self.device.pk).update(_config_context_data=None) + self._make_runner()._render_for_model('dcim.device', pks=[self.device.pk]) + + self.device.refresh_from_db() + self.assertEqual(self.device._config_context_data, {'foo': 'bar'}) + + def test_job_idempotent_on_repopulated_cache(self): + runner = self._make_runner() + runner._render_for_model('dcim.device', pks=[self.device.pk]) + self.device.refresh_from_db() + first = self.device._config_context_data + + Device.objects.filter(pk=self.device.pk).update(_config_context_data=None) + runner._render_for_model('dcim.device', pks=[self.device.pk]) + self.device.refresh_from_db() + self.assertEqual(self.device._config_context_data, first) + + def test_job_skips_stale_write_on_concurrent_invalidation(self): + """ + If an invalidation bumps the generation counter after the renderer captured it but before + the result is written, the compare-and-set write must be rejected so a stale value is never + persisted (the row stays NULL for the follow-up job to re-render). + """ + Device.objects.filter(pk=self.device.pk).update( + _config_context_data=None, _config_context_generation=1 + ) + runner = self._make_runner() + + def racing_render(device_self): + # Simulate a concurrent invalidation committing mid-render. + Device.objects.filter(pk=device_self.pk).update( + _config_context_generation=F('_config_context_generation') + 1 + ) + return {'stale': True} + + with mock.patch.object(Device, 'render_config_context', autospec=True, side_effect=racing_render): + runner._render_for_model('dcim.device', pks=[self.device.pk]) + + self.device.refresh_from_db() + self.assertIsNone(self.device._config_context_data) + self.assertEqual(self.device._config_context_generation, 2) + + # A subsequent render against the current generation succeeds. + runner._render_for_model('dcim.device', pks=[self.device.pk]) + self.device.refresh_from_db() + self.assertEqual(self.device._config_context_data, {'foo': 'bar'}) + + def test_run_rescans_for_caches_nulled_mid_sweep(self): + """ + An invalidation that commits while this job is already RUNNING coalesces into it (rather + than enqueuing a follow-up), so run() must re-scan after each pass. Otherwise a cache that + is NULLed after its row has been passed over would be stranded on the on-demand read path + with no job left to repopulate it. + """ + device_b = Device.objects.create( + name='Device B', device_type=self.device.device_type, role=self.device.role, + site=self.device.site, + ) + # Device A needs rendering; Device B starts populated, so the first scan skips it. + Device.objects.filter(pk=self.device.pk).update(_config_context_data=None) + Device.objects.filter(pk=device_b.pk).update(_config_context_data={'old': True}) + + runner = self._make_runner() + real_render = Device.render_config_context + nulled = [] + + def render_then_null_b(device_self): + # While rendering Device A (the first pass), simulate a concurrent invalidation NULLing + # Device B's cache after B has already been passed over. + if device_self.pk == self.device.pk and not nulled: + nulled.append(True) + Device.objects.filter(pk=device_b.pk).update(_config_context_data=None) + return real_render(device_self) + + with mock.patch.object(Device, 'render_config_context', autospec=True, side_effect=render_then_null_b): + runner.run(model_label='dcim.device') + + # Both caches must be populated: A on the first pass, B on the re-scan. + self.device.refresh_from_db() + device_b.refresh_from_db() + self.assertEqual(self.device._config_context_data, {'foo': 'bar'}) + self.assertEqual(device_b._config_context_data, {'foo': 'bar'}) + + +class ConfigContextCacheJobEnqueueTest(TestCase): + """ + Invalidation NULLs the cache synchronously and enqueues the render job on transaction commit. + """ + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Mfr', slug='mfr') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='DT', slug='dt') + role = DeviceRole.objects.create(name='Role', slug='role') + site = Site.objects.create(name='Site', slug='site') + cls.device = Device.objects.create( + name='Device', device_type=devicetype, role=role, site=site, + ) + + def test_invalidation_enqueues_render_job_on_commit(self): + _set_cache(self.device, {'x': 1}) + self.assertFalse(Job.objects.filter(name=RenderConfigContextJob.name).exists()) + + with self.captureOnCommitCallbacks(execute=True): + invalidate_config_context_for_objects('dcim.device', [self.device.pk]) + + self.assertTrue(Job.objects.filter(name=RenderConfigContextJob.name).exists()) + + +class CacheHelperTest(TestCase): + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Mfr', slug='mfr') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='DT', slug='dt') + role = DeviceRole.objects.create(name='Role', slug='role') + site = Site.objects.create(name='Site', slug='site') + cls.device = Device.objects.create( + name='Device', device_type=devicetype, role=role, site=site, + ) + + def test_invalidate_for_objects_nulls_cache(self): + _set_cache(self.device, {'x': 1}) + invalidate_config_context_for_objects('dcim.device', [self.device.pk]) + self.assertIsNone(_get_cache(self.device)) + + def test_invalidate_for_objects_bumps_generation(self): + _set_cache(self.device, {'x': 1}) + self.device.refresh_from_db() + before = self.device._config_context_generation + invalidate_config_context_for_objects('dcim.device', [self.device.pk]) + self.device.refresh_from_db() + self.assertEqual(self.device._config_context_generation, before + 1) + + def test_invalidate_with_empty_args_is_noop(self): + invalidate_config_context_for_objects('dcim.device', []) + + +class VirtualMachineInvalidationTest(TestCase): + """ + The invalidation signals must cover VirtualMachine, not just Device. + """ + + @classmethod + def setUpTestData(cls): + cls.site1 = Site.objects.create(name='Site 1', slug='site-1') + cls.site2 = Site.objects.create(name='Site 2', slug='site-2') + cls.role = DeviceRole.objects.create(name='Role', slug='role') + cls.role2 = DeviceRole.objects.create(name='Role 2', slug='role-2') + clustertype = ClusterType.objects.create(name='CT', slug='ct') + cls.cluster = Cluster.objects.create(name='Cluster', type=clustertype) + cls.vm_in_scope = VirtualMachine.objects.create(name='In scope', site=cls.site1, role=cls.role) + cls.vm_out_of_scope = VirtualMachine.objects.create(name='Out of scope', site=cls.site2, role=cls.role) + + def setUp(self): + _set_cache(self.vm_in_scope, {'cached': True}) + _set_cache(self.vm_out_of_scope, {'cached': True}) + + def test_configcontext_save_invalidates_in_scope_vm_only(self): + cc = ConfigContext.objects.create(name='CC', weight=100, data={'x': 1}) + cc.sites.add(self.site1) + _set_cache(self.vm_in_scope, {'cached': True}) + _set_cache(self.vm_out_of_scope, {'cached': True}) + + cc.data = {'x': 2} + cc.save() + + self.assertIsNone(_get_cache(self.vm_in_scope)) + self.assertEqual(_get_cache(self.vm_out_of_scope), {'cached': True}) + + def test_vm_role_change_invalidates(self): + self.vm_in_scope.refresh_from_db() + self.vm_in_scope.snapshot() + self.vm_in_scope.role = self.role2 + self.vm_in_scope.save() + self.assertIsNone(_get_cache(self.vm_in_scope)) + + def test_vm_tag_add_invalidates(self): + tag = Tag.objects.create(name='Tag', slug='tag') + self.vm_in_scope.tags.add(tag) + self.assertIsNone(_get_cache(self.vm_in_scope)) + + def test_device_only_scope_does_not_invalidate_vm(self): + # A context scoped by device_type can never apply to a VM, so its changes must not touch + # VM caches. + manufacturer = Manufacturer.objects.create(name='Mfr', slug='mfr') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='DT', slug='dt') + cc = ConfigContext.objects.create(name='CC', weight=100, data={'x': 1}) + cc.device_types.add(devicetype) + _set_cache(self.vm_in_scope, {'cached': True}) + + cc.data = {'x': 2} + cc.save() + + self.assertEqual(_get_cache(self.vm_in_scope), {'cached': True}) + + +class ConfigContextUpstreamChangeInvalidationTest(TestCase): + """ + Changes to intermediate/hierarchical objects (not the Device/VM itself) must invalidate the + affected caches: direct FK changes on an intermediate model, and ltree reparents. + """ + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Mfr', slug='mfr') + cls.devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='DT', slug='dt') + cls.role = DeviceRole.objects.create(name='Role', slug='role') + + def _make_device(self, site): + device = Device.objects.create( + name=f'Device {site.slug}', device_type=self.devicetype, role=self.role, site=site, + ) + _set_cache(device, {'cached': True}) + return device + + def test_direct_upstream_site_region_change_invalidates(self): + region1 = Region.objects.create(name='R1', slug='r1') + region2 = Region.objects.create(name='R2', slug='r2') + site = Site.objects.create(name='Site', slug='site', region=region1) + device = self._make_device(site) + + site.snapshot() + site.region = region2 + site.save() + + self.assertIsNone(_get_cache(device)) + + def test_region_reparent_invalidates(self): + region_a = Region.objects.create(name='A', slug='a') + region_b = Region.objects.create(name='B', slug='b') + site = Site.objects.create(name='Site', slug='site', region=region_b) + device = self._make_device(site) + + region_b.snapshot() + region_b.parent = region_a + region_b.save() + + self.assertIsNone(_get_cache(device)) + + def test_direct_upstream_cluster_type_change_invalidates_vm(self): + ct1 = ClusterType.objects.create(name='CT1', slug='ct1') + ct2 = ClusterType.objects.create(name='CT2', slug='ct2') + cluster = Cluster.objects.create(name='Cluster', type=ct1) + site = Site.objects.create(name='Site', slug='site') + vm = VirtualMachine.objects.create(name='VM', site=site, role=self.role, cluster=cluster) + _set_cache(vm, {'cached': True}) + + cluster.snapshot() + cluster.type = ct2 + cluster.save() + + self.assertIsNone(_get_cache(vm)) + + def test_direct_upstream_cluster_group_change_invalidates_vm(self): + ct = ClusterType.objects.create(name='CT', slug='ct') + cg1 = ClusterGroup.objects.create(name='CG1', slug='cg1') + cg2 = ClusterGroup.objects.create(name='CG2', slug='cg2') + cluster = Cluster.objects.create(name='Cluster', type=ct, group=cg1) + site = Site.objects.create(name='Site', slug='site') + vm = VirtualMachine.objects.create(name='VM', site=site, role=self.role, cluster=cluster) + _set_cache(vm, {'cached': True}) + + cluster.snapshot() + cluster.group = cg2 + cluster.save() + + self.assertIsNone(_get_cache(vm)) + + def test_direct_upstream_cluster_scope_change_invalidates_vm(self): + # A Cluster's effective site lives in the cached `_site_id`, which virtualization.signals + # propagates onto every VM in the cluster with a bulk UPDATE emitting no post_save. The + # VMs' site-based matching shifts with it, so the Cluster save must invalidate them. + ct = ClusterType.objects.create(name='CT', slug='ct') + site1 = Site.objects.create(name='Site 1', slug='site-1') + site2 = Site.objects.create(name='Site 2', slug='site-2') + cluster = Cluster.objects.create(name='Cluster', type=ct, scope=site1) + vm = VirtualMachine.objects.create(name='VM', role=self.role, cluster=cluster) + self.assertEqual(vm.site, site1) + _set_cache(vm, {'cached': True}) + + cluster.snapshot() + cluster.scope = site2 + cluster.save() + + self.assertIsNone(_get_cache(vm)) + + def test_direct_upstream_tenant_group_change_invalidates_device(self): + tg1 = TenantGroup.objects.create(name='TG1', slug='tg1') + tg2 = TenantGroup.objects.create(name='TG2', slug='tg2') + tenant = Tenant.objects.create(name='Tenant', slug='tenant', group=tg1) + site = Site.objects.create(name='Site', slug='site') + device = Device.objects.create( + name='Device', device_type=self.devicetype, role=self.role, site=site, tenant=tenant, + ) + _set_cache(device, {'cached': True}) + + tenant.snapshot() + tenant.group = tg2 + tenant.save() + + self.assertIsNone(_get_cache(device)) + + def test_direct_upstream_tenant_group_change_invalidates_vm(self): + tg1 = TenantGroup.objects.create(name='TG1', slug='tg1') + tg2 = TenantGroup.objects.create(name='TG2', slug='tg2') + tenant = Tenant.objects.create(name='Tenant', slug='tenant', group=tg1) + site = Site.objects.create(name='Site', slug='site') + vm = VirtualMachine.objects.create(name='VM', site=site, role=self.role, tenant=tenant) + _set_cache(vm, {'cached': True}) + + tenant.snapshot() + tenant.group = tg2 + tenant.save() + + self.assertIsNone(_get_cache(vm)) + + def test_m2m_post_clear_invalidates(self): + site = Site.objects.create(name='Site', slug='site') + device = self._make_device(site) + cc = ConfigContext.objects.create(name='CC', weight=100, data={'x': 1}) + cc.sites.add(site) + _set_cache(device, {'cached': True}) + + cc.sites.clear() + + self.assertIsNone(_get_cache(device)) + + +class ConfigContextScopeParityTest(TestCase): + """ + The inverse matcher (ConfigContext.get_affected_objects()) must agree exactly with the forward + matcher (ConfigContextQuerySet.get_for_object()) across every scope dimension, including + hierarchy expansion and device-only dimensions. + """ + + @classmethod + def setUpTestData(cls): + # Hierarchies + cls.region_parent = Region.objects.create(name='Region Parent', slug='region-parent') + cls.region_child = Region.objects.create(name='Region Child', slug='region-child', parent=cls.region_parent) + cls.sg_parent = SiteGroup.objects.create(name='SG Parent', slug='sg-parent') + cls.sg_child = SiteGroup.objects.create(name='SG Child', slug='sg-child', parent=cls.sg_parent) + cls.role_parent = DeviceRole.objects.create(name='Role Parent', slug='role-parent') + cls.role_child = DeviceRole.objects.create(name='Role Child', slug='role-child', parent=cls.role_parent) + cls.plat_parent = Platform.objects.create(name='Plat Parent', slug='plat-parent') + cls.plat_child = Platform.objects.create(name='Plat Child', slug='plat-child', parent=cls.plat_parent) + cls.tg = TenantGroup.objects.create(name='TG', slug='tg') + cls.tenant = Tenant.objects.create(name='Tenant', slug='tenant', group=cls.tg) + + cls.site_a = Site.objects.create( + name='Site A', slug='site-a', region=cls.region_child, group=cls.sg_child + ) + cls.site_b = Site.objects.create(name='Site B', slug='site-b') + cls.loc_parent = Location.objects.create(name='Loc Parent', slug='loc-parent', site=cls.site_a) + cls.loc_child = Location.objects.create( + name='Loc Child', slug='loc-child', site=cls.site_a, parent=cls.loc_parent + ) + + manufacturer = Manufacturer.objects.create(name='Mfr', slug='mfr') + cls.devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='DT', slug='dt') + clustertype = ClusterType.objects.create(name='CT', slug='ct') + clustergroup = ClusterGroup.objects.create(name='CG', slug='cg') + cls.cluster = Cluster.objects.create(name='Cluster', type=clustertype, group=clustergroup) + cls.tag = Tag.objects.create(name='Tag', slug='tag') + + cls.d_full = Device.objects.create( + name='d-full', device_type=cls.devicetype, role=cls.role_child, site=cls.site_a, + location=cls.loc_child, platform=cls.plat_child, tenant=cls.tenant, cluster=cls.cluster, + ) + cls.d_full.tags.add(cls.tag) + cls.d_min = Device.objects.create( + name='d-min', device_type=cls.devicetype, role=cls.role_parent, site=cls.site_b, + ) + cls.vm_full = VirtualMachine.objects.create( + name='vm-full', site=cls.site_a, role=cls.role_child, platform=cls.plat_child, + tenant=cls.tenant, cluster=cls.cluster, + ) + cls.vm_full.tags.add(cls.tag) + cls.vm_min = VirtualMachine.objects.create(name='vm-min', site=cls.site_b, role=cls.role_parent) + + def _assert_parity(self, cc): + device_qs, vm_qs = cc.get_affected_objects() + inverse_devices = set(device_qs.values_list('pk', flat=True)) + inverse_vms = set(vm_qs.values_list('pk', flat=True)) + + forward_devices = { + d.pk for d in Device.objects.all() + if ConfigContext.objects.get_for_object(d).filter(pk=cc.pk).exists() + } + forward_vms = { + v.pk for v in VirtualMachine.objects.all() + if ConfigContext.objects.get_for_object(v).filter(pk=cc.pk).exists() + } + self.assertEqual(inverse_devices, forward_devices, f"device mismatch for {cc.name}") + self.assertEqual(inverse_vms, forward_vms, f"VM mismatch for {cc.name}") + + def test_scope_parity_across_dimensions(self): + scopes = { + 'regions': [self.region_parent], # hierarchy: matches descendant region_child + 'site_groups': [self.sg_parent], # hierarchy + 'sites': [self.site_a], + 'locations': [self.loc_parent], # device-only, hierarchy + 'device_types': [self.devicetype], # device-only + 'roles': [self.role_parent], # hierarchy + 'platforms': [self.plat_parent], # hierarchy + 'cluster_types': [self.cluster.type], + 'cluster_groups': [self.cluster.group], + 'clusters': [self.cluster], + 'tenant_groups': [self.tg], # direct (immediate group only) + 'tenants': [self.tenant], + 'tags': [self.tag], + } + for i, (field, items) in enumerate(scopes.items()): + cc = ConfigContext.objects.create(name=f'cc-{field}', weight=100 + i, data={field: True}) + getattr(cc, field).set(items) + self._assert_parity(cc) + + # A context with no scope matches every object. + cc_all = ConfigContext.objects.create(name='cc-all', weight=999, data={'all': True}) + self._assert_parity(cc_all) + + +class ConfigContextCacheQueryCountTest(TestCase): + """ + Proves the optimization: when caches are warm, get_config_context() issues no per-object + queries, whereas the cold fallback path scales with the number of objects. + """ + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Mfr', slug='mfr') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='DT', slug='dt') + role = DeviceRole.objects.create(name='Role', slug='role') + site = Site.objects.create(name='Site', slug='site') + for i in range(4): + Device.objects.create(name=f'Device {i}', device_type=devicetype, role=role, site=site) + ConfigContext.objects.create(name='A', weight=100, data={'a': 1}) + ConfigContext.objects.create(name='B', weight=200, data={'b': 2}) + + def test_warm_cache_avoids_per_object_queries(self): + # Cold: caches are NULL, so each object renders on demand. + Device.objects.update(_config_context_data=None) + cold_devices = list(Device.objects.all()) + with CaptureQueriesContext(connection) as cold: + for device in cold_devices: + device.get_config_context() + + # Warm the caches. + for device in Device.objects.all(): + Device.objects.filter(pk=device.pk).update(_config_context_data=device.render_config_context()) + + warm_devices = list(Device.objects.all()) + with CaptureQueriesContext(connection) as warm: + for device in warm_devices: + device.get_config_context() + + self.assertEqual(len(warm.captured_queries), 0) + self.assertGreaterEqual(len(cold.captured_queries), len(cold_devices)) + + +class ConditionalConfigContextAnnotationTest(TestCase): + """ + annotate_config_context_data(only_invalidated=True) is the list/detail read-path optimization: + it must compute the aggregation only for rows whose cache is NULL and leave it NULL for warm + rows, while a single query still serves a mix of warm and cold objects correctly via + get_config_context(). + """ + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Mfr', slug='mfr') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='DT', slug='dt') + role = DeviceRole.objects.create(name='Role', slug='role') + site = Site.objects.create(name='Site', slug='site') + cls.warm = Device.objects.create(name='Warm', device_type=devicetype, role=role, site=site) + cls.cold = Device.objects.create(name='Cold', device_type=devicetype, role=role, site=site) + ConfigContext.objects.create(name='A', weight=100, data={'a': 1}) + ConfigContext.objects.create(name='B', weight=200, data={'b': 2}) + + def setUp(self): + # Warm one device with a sentinel cache, leave the other invalidated. + _set_cache(self.warm, {'sentinel': True}) + Device.objects.filter(pk=self.cold.pk).update(_config_context_data=None) + + def test_annotation_skips_warm_rows(self): + rows = { + d.pk: d + for d in Device.objects.annotate_config_context_data(only_invalidated=True) + } + # Warm row: the aggregation must not have run; the annotated value is NULL. + self.assertIsNone(rows[self.warm.pk].config_context_data) + # Cold row: the aggregation ran and produced the ordered list of context data. + self.assertEqual(rows[self.cold.pk].config_context_data, [{'a': 1}, {'b': 2}]) + + def test_single_query_serves_mixed_warm_and_cold(self): + qs = Device.objects.annotate_config_context_data(only_invalidated=True) + with CaptureQueriesContext(connection) as ctx: + rows = {d.pk: d.get_config_context() for d in qs} + # The warm row returns its cached sentinel; the cold row is rendered from the annotation. + self.assertEqual(rows[self.warm.pk], {'sentinel': True}) + self.assertEqual(rows[self.cold.pk], {'a': 1, 'b': 2}) + # A single query backs the whole page — no per-object fallback to get_for_object(). + self.assertEqual(len(ctx.captured_queries), 1) + + +# Resolving a ConfigContext's affected object set reads every dimension of its scope, whether or +# not that dimension is populated (see ConfigContext._get_affected_object_filters). A router which +# named only Device and VirtualMachine would therefore leave those scope lookups unchecked, so the +# ConfigContext-driven tests below name them all. +CC_SCOPE_MODELS = ( + Cluster, + ClusterGroup, + ClusterType, + DeviceRole, + DeviceType, + Location, + Platform, + Region, + Site, + SiteGroup, + Tag, + Tenant, + TenantGroup, +) + + +class ConfigContextInvalidationRoutingTest(TestCase): + """ + Every query the invalidation makes must be issued against the connection the triggering object + was saved on — the alias the signal supplies as `using` — rather than being resolved anew by + DATABASE_ROUTERS. A router which resolved them elsewhere would read the affected PKs from, and + NULL the cache in, a database other than the one holding the triggering change. + + PinnedConnectionRouter raises on any unpinned read or write of the models it is given. Each + test omits the model being saved or deleted, as Django routes that operation itself. + """ + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Mfr', slug='mfr') + cls.devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='DT', slug='dt') + cls.role = DeviceRole.objects.create(name='Role', slug='role') + cls.region = Region.objects.create(name='Region', slug='region') + cls.site_a = Site.objects.create(name='Site A', slug='site-a', region=cls.region) + cls.site_b = Site.objects.create(name='Site B', slug='site-b') + cls.platform = Platform.objects.create(name='Platform', slug='platform') + cls.location = Location.objects.create(name='Location', slug='location', site=cls.site_a) + cls.device = Device.objects.create( + name='Device', + device_type=cls.devicetype, + role=cls.role, + site=cls.site_a, + location=cls.location, + platform=cls.platform, + ) + cls.cluster_type = ClusterType.objects.create(name='Cluster Type', slug='cluster-type') + cls.cluster = Cluster.objects.create(name='Cluster', type=cls.cluster_type, scope=cls.site_a) + cls.vm = VirtualMachine.objects.create( + name='VM', cluster=cls.cluster, role=cls.role, platform=cls.platform + ) + + def test_helper_pins_update_to_given_connection(self): + with override_settings(DATABASE_ROUTERS=[PinnedConnectionRouter(Device, VirtualMachine)]): + invalidate_config_context_for_objects('dcim.device', [self.device.pk], using='default') + + def test_helper_resolves_one_alias_for_update_and_enqueue(self): + # Called without an alias, the UPDATE falls to the router but transaction.on_commit() + # would fall to 'default', which need not be the same database: the callback would then + # be attached to a connection other than the one being written, and could fire before + # the UPDATE it waits on. Both must be bound to the alias the router chooses. + with mock.patch('extras.cache.transaction.on_commit') as on_commit: + invalidate_config_context_for_objects('dcim.device', [self.device.pk]) + + self.assertEqual(on_commit.call_args.kwargs['using'], router.db_for_write(Device)) + + def test_location_site_change_pins_device_query(self): + location = Location.objects.get(pk=self.location.pk) + location.site_id = self.site_b.pk + with override_settings(DATABASE_ROUTERS=[PinnedConnectionRouter(Device)]): + location.save() + + def test_cluster_scope_change_pins_vm_query(self): + # Snapshotted, so that the handler's watched-field check runs for real: absent a + # snapshot _changed_fields() returns True conservatively, and the query under test would + # be issued even if the trigger named a field the model does not have. Asserting the + # cache is cleared confirms the change was actually recognized as scope-relevant. + cluster = Cluster.objects.get(pk=self.cluster.pk) + _set_cache(self.vm, {'cached': True}) + cluster.snapshot() + cluster.scope = self.site_b + with override_settings(DATABASE_ROUTERS=[PinnedConnectionRouter(VirtualMachine)]): + cluster.save() + + self.assertIsNone(_get_cache(self.vm)) + + def test_region_reparent_pins_device_and_vm_queries(self): + parent = Region.objects.create(name='Parent', slug='parent') + region = Region.objects.get(pk=self.region.pk) + region.parent = parent + with override_settings(DATABASE_ROUTERS=[PinnedConnectionRouter(Device, VirtualMachine)]): + region.save() + + def test_reparent_handler_pins_subtree_queries_to_given_connection(self): + # The reparent handler resolves the moved node's post-move path and the PKs of its + # subtree before it can name the affected objects, and both reads must follow the + # saving connection. The handler is invoked directly so that the reparented model can + # be named in the router: saving the Region under it would trip on the unpinned cycle + # check in the ltree base save, which this handler does not control. + parent = Region.objects.create(name='Parent', slug='parent') + region = Region.objects.get(pk=self.region.pk) + region.parent = parent + region.save() + + handler = _make_reparent_handler('site__region__in', 'site__region__in') + with override_settings(DATABASE_ROUTERS=[PinnedConnectionRouter(Device, VirtualMachine, Region)]): + handler(sender=Region, instance=region, created=False, using='default') + + def test_configcontext_save_pins_device_and_vm_queries(self): + # Both a nested (ltree) and a direct scope dimension are populated, so that the path + # lookup and the PK lookup which resolve them are each exercised. + cc = ConfigContext.objects.create(name='CC', weight=100, data={'a': 1}) + cc.sites.add(self.site_a) + cc.regions.add(self.region) + cc.data = {'a': 2} + with override_settings( + DATABASE_ROUTERS=[PinnedConnectionRouter(Device, VirtualMachine, *CC_SCOPE_MODELS)] + ): + cc.save() + + def test_configcontext_delete_pins_device_and_vm_queries(self): + cc = ConfigContext.objects.create(name='CC', weight=100, data={'a': 1}) + cc.sites.add(self.site_a) + cc.regions.add(self.region) + with override_settings( + DATABASE_ROUTERS=[PinnedConnectionRouter(Device, VirtualMachine, *CC_SCOPE_MODELS)] + ): + cc.delete() + + def test_configcontext_m2m_add_pins_device_and_vm_queries(self): + cc = ConfigContext.objects.create(name='CC', weight=100, data={'a': 1}) + with override_settings( + DATABASE_ROUTERS=[PinnedConnectionRouter(Device, VirtualMachine, *CC_SCOPE_MODELS)] + ): + cc.sites.add(self.site_a) + + def test_configcontext_m2m_remove_pins_device_and_vm_queries(self): + # post_remove additionally resolves the removed scope items via + # invalidate_for_scope_delta(), which must be pinned too. + cc = ConfigContext.objects.create(name='CC', weight=100, data={'a': 1}) + cc.sites.add(self.site_a) + with override_settings( + DATABASE_ROUTERS=[PinnedConnectionRouter(Device, VirtualMachine, *CC_SCOPE_MODELS)] + ): + cc.sites.remove(self.site_a) + + def test_configcontext_nested_m2m_remove_pins_scope_delta_queries(self): + # A nested (ltree) scope dimension resolves the removed items' paths as well. Region is + # named so that read is checked rather than merely performed. + cc = ConfigContext.objects.create(name='CC', weight=100, data={'a': 1}) + cc.regions.add(self.region) + with override_settings( + DATABASE_ROUTERS=[PinnedConnectionRouter(Device, VirtualMachine, *CC_SCOPE_MODELS)] + ): + cc.regions.remove(self.region) + + def test_upstream_delete_pins_device_and_vm_queries(self): + # Platform is a SET_NULL feeder on both Device and VirtualMachine. + platform = Platform.objects.create(name='Spare', slug='spare') + with override_settings(DATABASE_ROUTERS=[PinnedConnectionRouter(Device, VirtualMachine)]): + platform.delete() + + def test_device_tag_change_pins_device_query(self): + tag = Tag.objects.create(name='Tag', slug='tag') + with override_settings(DATABASE_ROUTERS=[PinnedConnectionRouter(Device)]): + self.device.tags.add(tag) + + +# An alias which is deliberately absent from DATABASES. Every query issued against it would raise +# ConnectionDoesNotExist, so the tests below stop at the boundary where the alias is handed off +# (the queryset's `_db`, or the `using` kwarg) rather than executing anything. +OTHER_ALIAS = 'other' + + +class ConfigContextInvalidationAliasThreadingTest(TestCase): + """ + ConfigContextInvalidationRoutingTest establishes that no query in the invalidation path is left + for DATABASE_ROUTERS to resolve. That is only half of the requirement: a query pinned to the + wrong alias consults no router either. These tests close the other half by handing each entry + point an alias of their own choosing and asserting that *that* alias is what reaches the + queryset and the enqueue — which a hardcoded `.using('default')` would fail. + + The alias names no real connection, so nothing here may execute a query against it. Each test + patches the boundary immediately below the code under test, leaving the querysets unevaluated. + """ + def test_helper_pins_update_and_enqueue_to_supplied_alias(self): + with ( + mock.patch('extras.cache.chunked_update', return_value=1) as chunked, + mock.patch('extras.cache.transaction.on_commit') as on_commit, + ): + invalidate_config_context_for_objects('dcim.device', [1], using=OTHER_ALIAS) + + self.assertEqual(chunked.call_args.args[0]._db, OTHER_ALIAS) + self.assertEqual(on_commit.call_args.kwargs['using'], OTHER_ALIAS) + + def test_helper_pins_update_to_the_alias_it_resolves(self): + # Called without an alias, the UPDATE and the enqueue must still agree: the alias the + # helper resolves for on_commit() (asserted by the routing test above) is the one the + # UPDATE has to carry, or the callback waits on a connection other than the one written. + with ( + mock.patch('extras.cache.chunked_update', return_value=1) as chunked, + mock.patch('extras.cache.transaction.on_commit') as on_commit, + ): + invalidate_config_context_for_objects('dcim.device', [1]) + + self.assertEqual(chunked.call_args.args[0]._db, router.db_for_write(Device)) + self.assertEqual(chunked.call_args.args[0]._db, on_commit.call_args.kwargs['using']) + + def test_configcontext_helper_forwards_alias(self): + cc = ConfigContext(name='CC', weight=100, data={}) + affected = (Device.objects.using(OTHER_ALIAS), VirtualMachine.objects.using(OTHER_ALIAS)) + with ( + mock.patch.object(ConfigContext, 'get_affected_objects', return_value=affected) as get_affected, + mock.patch('extras.cache.invalidate_config_context_for_objects') as invalidate, + ): + invalidate_config_context_for_configcontext(cc, using=OTHER_ALIAS) + + self.assertEqual(get_affected.call_args.kwargs['using'], OTHER_ALIAS) + self.assertEqual([c.kwargs['using'] for c in invalidate.call_args_list], [OTHER_ALIAS, OTHER_ALIAS]) + self.assertEqual([c.args[1]._db for c in invalidate.call_args_list], [OTHER_ALIAS, OTHER_ALIAS]) + + def test_scope_delta_pins_pk_selects_to_supplied_alias(self): + # 'sites' is a direct (non-nested, non-tag) scope dimension, so the function resolves it + # without a query of its own and the Device/VM PK selects are the only reads to check. + with mock.patch('extras.cache.invalidate_config_context_for_objects') as invalidate: + invalidate_for_scope_delta('sites', [1], using=OTHER_ALIAS) + + self.assertEqual([c.kwargs['using'] for c in invalidate.call_args_list], [OTHER_ALIAS, OTHER_ALIAS]) + self.assertEqual([c.args[1]._db for c in invalidate.call_args_list], [OTHER_ALIAS, OTHER_ALIAS]) + + def test_get_affected_objects_binds_querysets_to_supplied_alias(self): + cc = ConfigContext(name='CC', weight=100, data={}) + with mock.patch.object( + ConfigContext, '_get_affected_object_filters', return_value=(Q(), Q()) + ) as get_filters: + device_qs, vm_qs = cc.get_affected_objects(using=OTHER_ALIAS) + + self.assertEqual(get_filters.call_args.kwargs['using'], OTHER_ALIAS) + self.assertEqual(device_qs._db, OTHER_ALIAS) + self.assertEqual(vm_qs._db, OTHER_ALIAS) + + def test_upstream_handler_forwards_alias_to_helper(self): + # The receivers take their alias from the signal; the handler is invoked directly so that + # an alias other than the one the test connection would supply can be threaded through it. + handler = _make_direct_upstream_handler(('name',), 'platform_id', 'platform_id') + with mock.patch('extras.signals.invalidate_config_context_for_objects') as invalidate: + handler(sender=Platform, instance=Platform(pk=1, name='Platform'), created=False, using=OTHER_ALIAS) + + self.assertEqual([c.kwargs['using'] for c in invalidate.call_args_list], [OTHER_ALIAS, OTHER_ALIAS]) + self.assertEqual([c.args[1]._db for c in invalidate.call_args_list], [OTHER_ALIAS, OTHER_ALIAS]) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 3a452edce..cb8308eaf 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -1,34 +1,54 @@ import datetime import json +import uuid from collections import defaultdict +from contextlib import contextmanager from decimal import Decimal from unittest.mock import patch import django_filters from django.core.exceptions import ValidationError -from django.db import connection +from django.db import DEFAULT_DB_ALIAS, connection, connections, transaction from django.db.models import QuerySet -from django.test import tag +from django.db.models.signals import pre_delete +from django.test import RequestFactory, override_settings, tag from django.test.utils import CaptureQueriesContext from django.urls import reverse from rest_framework import status -from core.models import ObjectChange, ObjectType +from core.choices import ObjectChangeActionChoices +from core.models import Job, ObjectChange, ObjectType from dcim.filtersets import SiteFilterSet from dcim.forms import SiteImportForm from dcim.models import Manufacturer, Rack, Site from dcim.tables import SiteTable from extras.choices import * +from extras.constants import CUSTOMFIELD_JOB_TIMEOUT from extras.filters import MissingKeyAwareFilterMixin, missing_key_aware_filter_factory +from extras.jobs import ( + CustomFieldProvisioningJob, + CustomFieldPurgeJob, + provision_custom_field, + purge_custom_field, +) from extras.models import CustomField, CustomFieldChoiceSet from ipam.models import VLAN from netbox.choices import CSVDelimiterChoices, ImportFormatChoices from netbox.context import query_cache +from netbox.context_managers import event_tracking +from netbox.tables.columns import CustomFieldColumn +from utilities.exceptions import AbortRequest from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter from utilities.testing import APITestCase, TestCase from virtualization.models import VirtualMachine +def get_primary_table_queries(queries, model): + """Return the SQL of captured queries that read from the model's table as the primary relation.""" + table = connection.ops.quote_name(model._meta.db_table) + return [q['sql'] for q in queries if f'FROM {table}' in q['sql']] + + class CustomFieldTestCase(TestCase): @classmethod @@ -78,6 +98,50 @@ class CustomFieldTestCase(TestCase): instance.refresh_from_db() self.assertIsNone(instance.custom_field_data.get(cf.name)) + def test_nulls_first_ordering(self): + """ + Verify that CustomFieldColumn.order() places null values first or last according to the + custom field's nulls_first attribute. + """ + cf = CustomField.objects.create( + name='order_field', + type=CustomFieldTypeChoices.TYPE_INTEGER, + required=False + ) + cf.object_types.set([self.object_type]) + + # Assign values to two of the three sites, leaving the third null + site_a = Site.objects.get(name='Site A') + site_a.custom_field_data[cf.name] = 1 + site_a.save() + site_b = Site.objects.get(name='Site B') + site_b.custom_field_data[cf.name] = 2 + site_b.save() + site_c = Site.objects.get(name='Site C') # no value (null) + + column = CustomFieldColumn(cf) + + # nulls_first=True (default): null value sorts before populated values when ascending + cf.nulls_first = True + queryset, _ = column.order(Site.objects.all(), is_descending=False) + self.assertEqual(list(queryset), [site_c, site_a, site_b]) + + # nulls_first=False: null value sorts after populated values when ascending + cf.nulls_first = False + queryset, _ = column.order(Site.objects.all(), is_descending=False) + self.assertEqual(list(queryset), [site_a, site_b, site_c]) + + # Null placement is independent of sort direction: nulls_first=True keeps the null value + # first even when sorting descending + cf.nulls_first = True + queryset, _ = column.order(Site.objects.all(), is_descending=True) + self.assertEqual(list(queryset), [site_c, site_b, site_a]) + + # nulls_first=False keeps the null value last even when sorting descending + cf.nulls_first = False + queryset, _ = column.order(Site.objects.all(), is_descending=True) + self.assertEqual(list(queryset), [site_b, site_a, site_c]) + def test_longtext_field(self): value = 'A' * 256 @@ -638,11 +702,15 @@ class CustomFieldTestCase(TestCase): self.assertNotIn('field1', site.custom_field_data) self.assertEqual(site.custom_field_data['field2'], FIELD_DATA) - @patch('extras.models.customfields.CUSTOMFIELD_DATA_BATCH_SIZE', 2) + @override_settings(BULK_UPDATE_CHUNK_SIZE=2) def test_batched_object_data_updates(self): """ Provisioning, renaming, and removing custom field data is applied in batches. Use a small batch size to ensure the data on every object is updated across multiple batches. + + BULK_UPDATE_CHUNK_SIZE doubles as the threshold above which an update is handed to a + background job, so overriding it this low also puts provisioning and removal onto the + deferred path; the jobs are run here in place of the worker which would ordinarily do so. """ # The existing sites (created in setUpTestData) span multiple batches of size 2 site_count = Site.objects.count() @@ -655,12 +723,15 @@ class CustomFieldTestCase(TestCase): default='foo' ) cf.object_types.set([self.object_type]) + self.assertTrue(provision_custom_field(cf.pk, [self.object_type.pk])) self.assertEqual( Site.objects.filter(custom_field_data__batched_field='foo').count(), site_count ) - # Renaming: the key is renamed on every existing object, preserving its value + # Renaming: the key is renamed on every existing object, preserving its value. This is + # always applied inline, so no job is involved. + cf.refresh_from_db() cf.name = 'renamed_field' cf.save() self.assertEqual( @@ -674,6 +745,7 @@ class CustomFieldTestCase(TestCase): # Removal: deleting the field strips the key from every existing object cf.delete() + self.assertTrue(purge_custom_field(cf.pk)) self.assertEqual( Site.objects.filter(custom_field_data__has_key='renamed_field').count(), 0 @@ -845,47 +917,6 @@ class CustomFieldTestCase(TestCase): table.order_by = aliases return table.data.data - def test_table_ordering_groups_objects_with_no_value(self): - """ - Objects holding no value sort together regardless of whether they store a JSON null or - carry no key at all, and numeric fields still sort numerically rather than lexically. - """ - cf = CustomField.objects.create( - name='sort_field', - type=CustomFieldTypeChoices.TYPE_INTEGER - ) - cf.object_types.set([self.object_type]) - - sites = list(Site.objects.order_by('name')) - # Site A holds a value, Site B an explicit null, Site C no key whatsoever - Site.objects.filter(pk=sites[0].pk).update(custom_field_data={'sort_field': 20}) - Site.objects.filter(pk=sites[1].pk).update(custom_field_data={'sort_field': None}) - Site.objects.filter(pk=sites[2].pk).update(custom_field_data={}) - extra = Site.objects.create( - name='Site D', slug='site-d', custom_field_data={'sort_field': 100} - ) - - ordered = self.order_sites_by('cf_sort_field') - self.assertEqual( - [s.pk for s in ordered][:2], - [sites[0].pk, extra.pk], - "20 must sort before 100 (numerically, not lexically) ahead of the empty rows" - ) - self.assertEqual( - {s.pk for s in ordered[2:]}, - {sites[1].pk, sites[2].pk}, - "the JSON-null and missing-key rows must group together at the end" - ) - - # Reversing the ordering carries the empty rows to the front, as it would SQL nulls - ordered = self.order_sites_by('-cf_sort_field') - self.assertEqual( - {s.pk for s in ordered[:2]}, - {sites[1].pk, sites[2].pk}, - "the JSON-null and missing-key rows must still group together" - ) - self.assertEqual([s.pk for s in ordered[2:]], [extra.pk, sites[0].pk]) - def test_table_ordering_breaks_ties_by_primary_key(self): """ Rows tying on the sort value -- every object holding no value ties on both sort keys -- @@ -918,40 +949,6 @@ class CustomFieldTestCase(TestCase): paginated.extend(site.pk for site in ordered[offset:offset + 4]) self.assertEqual(paginated, expected) - def test_table_ordering_composes_with_other_columns(self): - """ - A custom field column must contribute its sort keys to a multi-column ordering rather than - replace it. (The sort parameter is read with getlist(), and a saved TableConfig records an - ordering of arbitrary length.) - """ - cf = CustomField.objects.create( - name='sort_field', - type=CustomFieldTypeChoices.TYPE_INTEGER - ) - cf.object_types.set([self.object_type]) - - sites = list(Site.objects.order_by('name')) - # Ordering by the custom field alone would reverse the first two sites - Site.objects.filter(pk=sites[0].pk).update(custom_field_data={'sort_field': 2}) - Site.objects.filter(pk=sites[1].pk).update(custom_field_data={'sort_field': 1}) - Site.objects.filter(pk=sites[2].pk).update(custom_field_data={'sort_field': 3}) - - ordered = self.order_sites_by('name', 'cf_sort_field') - self.assertEqual( - [site.pk for site in ordered], - [site.pk for site in sites], - "the preceding sort key must survive the addition of a custom field column" - ) - - # A column named after the custom field column must likewise still apply - Site.objects.update(custom_field_data={'sort_field': 1}) - ordered = self.order_sites_by('cf_sort_field', '-name') - self.assertEqual( - [site.pk for site in ordered], - [site.pk for site in reversed(sites)], - "the trailing sort key must be applied before the primary key tie breaker" - ) - def test_table_ordering_tolerates_a_repeated_sort_alias(self): """ The sort parameter is read with getlist(), so the same custom field column can appear in @@ -1121,24 +1118,149 @@ class CustomFieldManagerTestCase(TestCase): custom_field.object_types.set([object_type]) def test_get_for_model(self): - self.assertEqual(CustomField.objects.get_for_model(Site).count(), 1) - self.assertEqual(CustomField.objects.get_for_model(VirtualMachine).count(), 0) + self.assertEqual(len(CustomField.objects.get_for_model(Site)), 1) + self.assertEqual(len(CustomField.objects.get_for_model(VirtualMachine)), 0) def test_get_for_model_caches_models_with_no_custom_fields(self): """ A model with no custom fields assigned must be served from the request cache like any other. - An empty QuerySet is falsy, so testing the cached value for truthiness would treat it as a - miss and re-query on every call. + An empty list is falsy, so testing the cached value for truthiness would treat it as a miss + and re-query on every call. """ token = query_cache.set(defaultdict(dict)) self.addCleanup(query_cache.reset, token) # Site has one custom field assigned, VirtualMachine none for model in (Site, VirtualMachine): - # Prime the cache, iterating so that the QuerySet's own result cache is populated too - list(CustomField.objects.get_for_model(model)) + CustomField.objects.get_for_model(model) # Prime the cache with self.assertNumQueries(0): - list(CustomField.objects.get_for_model(model)) + CustomField.objects.get_for_model(model) + + def test_get_defaults_for_model_is_cached(self): + """ + Every save of a custom-field-bearing object resolves the model's defaults, so the lookup + must be served from the request cache rather than re-queried each time. As above, a model + with no defaults caches an empty dict, which must not be mistaken for a miss. + """ + token = query_cache.set(defaultdict(dict)) + self.addCleanup(query_cache.reset, token) + + # Site has a field with a default, VirtualMachine none + for model in (Site, VirtualMachine): + CustomField.objects.get_defaults_for_model(model) + with self.assertNumQueries(0): + CustomField.objects.get_defaults_for_model(model) + + def test_get_defaults_for_model_shares_the_field_cache(self): + """ + The two lookups differ only in the statuses they select, so resolving a model's defaults must + be served from the fields get_for_model() has already fetched rather than re-querying them. + """ + token = query_cache.set(defaultdict(dict)) + self.addCleanup(query_cache.reset, token) + + CustomField.objects.get_for_model(Site) # Prime the field cache + + with self.assertNumQueries(0): + self.assertEqual(CustomField.objects.get_defaults_for_model(Site), {'text_field': 'foo'}) + + def test_get_defaults_for_model_returns_a_copy(self): + """ + Callers assign the returned dict directly to an object's custom field data and then mutate + it in place, so each must receive its own copy. Sharing the cached dict -- or the list held + by a multiple-value field's default -- would let one object's value alter another's. + """ + custom_field = CustomField(type=CustomFieldTypeChoices.TYPE_JSON, name='json_field', default=['foo']) + custom_field.save() + custom_field.object_types.set([ObjectType.objects.get_for_model(Site)]) + + token = query_cache.set(defaultdict(dict)) + self.addCleanup(query_cache.reset, token) + + defaults = CustomField.objects.get_defaults_for_model(Site) + defaults['text_field'] = 'bar' + defaults['json_field'].append('bar') + + self.assertEqual( + CustomField.objects.get_defaults_for_model(Site), + {'text_field': 'foo', 'json_field': ['foo']} + ) + + def test_creating_a_field_clears_the_cache(self): + """ + The cache spans a whole request -- and a whole script or job run -- so a field created + partway through one must not be hidden by what was read before it. get_defaults_for_model() + is the lookup which matters most here: Device and Module component instantiation resolves + the defaults of every component model it creates. + """ + token = query_cache.set(defaultdict(dict)) + self.addCleanup(query_cache.reset, token) + + CustomField.objects.get_for_model(VirtualMachine) # Prime the cache while none is assigned + + cf = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='vm_field', default='bar') + cf.save() + cf.object_types.set([ObjectType.objects.get_for_model(VirtualMachine)]) + + self.assertEqual([f.pk for f in CustomField.objects.get_for_model(VirtualMachine)], [cf.pk]) + self.assertEqual(CustomField.objects.get_defaults_for_model(VirtualMachine), {'vm_field': 'bar'}) + + def test_assigning_an_object_type_clears_the_cache(self): + token = query_cache.set(defaultdict(dict)) + self.addCleanup(query_cache.reset, token) + + CustomField.objects.get_for_model(VirtualMachine) # Prime the cache while none is assigned + + cf = CustomField.objects.get(name='text_field') + cf.object_types.add(ObjectType.objects.get_for_model(VirtualMachine)) + + self.assertEqual([f.pk for f in CustomField.objects.get_for_model(VirtualMachine)], [cf.pk]) + + def test_unassigning_an_object_type_clears_the_cache(self): + token = query_cache.set(defaultdict(dict)) + self.addCleanup(query_cache.reset, token) + + CustomField.objects.get_for_model(Site) # Prime the cache while the field is assigned + + cf = CustomField.objects.get(name='text_field') + cf.object_types.remove(ObjectType.objects.get_for_model(Site)) + + self.assertEqual(CustomField.objects.get_for_model(Site), []) + self.assertEqual(CustomField.objects.get_defaults_for_model(Site), {}) + + def test_changing_a_default_clears_the_cache(self): + """ + A field's default reaches the objects created after it is changed, so a change made partway + through a request must not be served from the value cached before it. + """ + token = query_cache.set(defaultdict(dict)) + self.addCleanup(query_cache.reset, token) + + self.assertEqual(CustomField.objects.get_defaults_for_model(Site), {'text_field': 'foo'}) + + cf = CustomField.objects.get(name='text_field') + cf.default = 'bar' + cf.save() + + self.assertEqual(CustomField.objects.get_defaults_for_model(Site), {'text_field': 'bar'}) + + def test_repeated_saves_do_not_requery_custom_fields(self): + """ + A bulk import creates thousands of objects within one request; resolving the defaults afresh + for each would add a query per object (see CustomFieldsMixin.save()). + """ + token = query_cache.set(defaultdict(dict)) + self.addCleanup(query_cache.reset, token) + + Site.objects.create(name='Site 1', slug='site-1') # Prime the caches + + with CaptureQueriesContext(connection) as ctx: + for i in range(2, 5): + Site.objects.create(name=f'Site {i}', slug=f'site-{i}') + + custom_field_queries = [q for q in ctx.captured_queries if 'extras_customfield' in q['sql']] + self.assertEqual(custom_field_queries, []) + self.assertEqual(Site.objects.filter(custom_field_data__text_field='foo').count(), 4) class CustomFieldAPITestCase(APITestCase): @@ -1264,6 +1386,24 @@ class CustomFieldAPITestCase(APITestCase): } sites[1].save() + # Labels for the choice set created in setUpTestData, used to build the expected + # API representation of selection custom fields ({'value': ..., 'label': ...}). + CHOICE_LABELS = {'foo': 'Foo', 'bar': 'Bar', 'baz': 'Baz'} + + @classmethod + def _select(cls, value): + """Return the expected API representation of a single selection choice.""" + if value is None: + return None + return {'value': value, 'label': cls.CHOICE_LABELS[value]} + + @classmethod + def _multiselect(cls, values): + """Return the expected API representation of a multiple selection value.""" + if values is None: + return None + return [cls._select(v) for v in values] + def test_get_custom_fields(self): TYPES = { CustomFieldTypeChoices.TYPE_TEXT: 'string', @@ -1337,14 +1477,163 @@ class CustomFieldAPITestCase(APITestCase): self.assertEqual(response.data['custom_fields']['datetime_field'], site2_cfvs['datetime_field']) self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field']) self.assertEqual(response.data['custom_fields']['json_field'], site2_cfvs['json_field']) - self.assertEqual(response.data['custom_fields']['select_field'], site2_cfvs['select_field']) - self.assertEqual(response.data['custom_fields']['multiselect_field'], site2_cfvs['multiselect_field']) + self.assertEqual(response.data['custom_fields']['select_field'], self._select(site2_cfvs['select_field'])) + self.assertEqual( + response.data['custom_fields']['multiselect_field'], + self._multiselect(site2_cfvs['multiselect_field']) + ) self.assertEqual(response.data['custom_fields']['object_field']['id'], site2_cfvs['object_field'].pk) self.assertEqual( [obj['id'] for obj in response.data['custom_fields']['multiobject_field']], [obj.pk for obj in site2_cfvs['multiobject_field']] ) + def test_get_object_selection_field_representation(self): + """ + Selection custom fields are rendered as an object exposing both the stored value and its + human-friendly label on read access (see #20897). + """ + site2 = Site.objects.get(name='Site 2') + url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) + self.add_permissions('dcim.view_site') + + response = self.client.get(url, **self.header) + + # A single selection value is rendered as a {value, label} object + self.assertEqual(response.data['custom_fields']['select_field'], { + 'value': 'bar', + 'label': 'Bar', + }) + + # A multiple selection value is rendered as a list of {value, label} objects + self.assertEqual(response.data['custom_fields']['multiselect_field'], [ + {'value': 'bar', 'label': 'Bar'}, + {'value': 'baz', 'label': 'Baz'}, + ]) + + def test_get_object_selection_field_unresolved_label(self): + """ + A stored selection value with no matching choice falls back to using the raw value as its label. + """ + site2 = Site.objects.get(name='Site 2') + site2.custom_field_data['select_field'] = 'stale' + site2.save() + url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) + self.add_permissions('dcim.view_site') + + response = self.client.get(url, **self.header) + self.assertEqual(response.data['custom_fields']['select_field'], { + 'value': 'stale', + 'label': 'stale', + }) + + def test_graphql_selection_field_representation_matches_rest(self): + site2 = Site.objects.get(name='Site 2') + self.add_permissions('dcim.view_site') + + query = f'{{ site(id: {site2.pk}) {{ custom_fields }} }}' + response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + data = json.loads(response.content) + self.assertNotIn('errors', data) + custom_fields = data['data']['site']['custom_fields'] + + self.assertEqual(custom_fields['select_field'], self._select('bar')) + self.assertEqual(custom_fields['multiselect_field'], self._multiselect(['bar', 'baz'])) + + def test_graphql_selection_field_unresolved_label(self): + site2 = Site.objects.get(name='Site 2') + site2.custom_field_data['select_field'] = 'stale' + site2.save() + self.add_permissions('dcim.view_site') + + query = f'{{ site(id: {site2.pk}) {{ custom_fields }} }}' + response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + data = json.loads(response.content) + self.assertNotIn('errors', data) + self.assertEqual(data['data']['site']['custom_fields']['select_field'], { + 'value': 'stale', + 'label': 'stale', + }) + + def test_graphql_non_selection_fields_pass_through_unchanged(self): + site2 = Site.objects.get(name='Site 2') + self.add_permissions('dcim.view_site') + + query = f'{{ site(id: {site2.pk}) {{ custom_fields }} }}' + response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + data = json.loads(response.content) + self.assertNotIn('errors', data) + custom_fields = data['data']['site']['custom_fields'] + + self.assertEqual(custom_fields['text_field'], 'bar') + self.assertEqual(custom_fields['integer_field'], 456) + self.assertEqual(custom_fields['boolean_field'], True) + + def test_graphql_selection_field_list_query_is_not_n_plus_one(self): + self.add_permissions('dcim.view_site') + Site.objects.bulk_create([Site(name=f'Site {i}', slug=f'site-{i}') for i in range(3, 13)]) + + query = '{ site_list { custom_fields } }' + with CaptureQueriesContext(connection) as ctx: + response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + data = json.loads(response.content) + self.assertNotIn('errors', data) + self.assertEqual(len(data['data']['site_list']), 12) + + # The capture window also holds one-off request queries, so assert on per-table counts, not the total. + site_queries = get_primary_table_queries(ctx.captured_queries, Site) + self.assertEqual( + len(site_queries), 1, + f'custom_field_data must be fetched by the site list query itself, got {site_queries}' + ) + custom_field_queries = get_primary_table_queries(ctx.captured_queries, CustomField) + self.assertEqual( + len(custom_field_queries), 1, + f'custom field definitions must be fetched once per request, got {custom_field_queries}' + ) + + def test_get_for_model_select_related_choice_set(self): + query_cache.set(None) + custom_fields = list(CustomField.objects.get_for_model(Site)) + with self.assertNumQueries(0): + resolved = {cf.name: cf.resolve_selection_value(cf.default) for cf in custom_fields} + self.assertEqual(resolved['select_field'], self._select('foo')) + self.assertEqual(resolved['multiselect_field'], self._multiselect(['foo'])) + + @tag('regression') + def test_update_selection_field_rejects_read_format(self): + """ + Selection fields are written by passing the raw value; submitting the {value, label} read + representation must be rejected with a clean 400, not a 500 (see #20897). + """ + site2 = Site.objects.get(name='Site 2') + url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) + self.add_permissions('dcim.change_site') + + # A single selection submitted as an object is rejected + response = self.client.patch( + url, {'custom_fields': {'select_field': {'value': 'foo', 'label': 'Foo'}}}, format='json', **self.header + ) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + + # A multiple selection submitted as a list of objects is rejected (must not raise a TypeError/500) + response = self.client.patch( + url, + {'custom_fields': {'multiselect_field': [{'value': 'foo', 'label': 'Foo'}]}}, + format='json', + **self.header + ) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + + # The stored values are unchanged + site2.refresh_from_db() + self.assertEqual(site2.custom_field_data['select_field'], 'bar') + self.assertEqual(site2.custom_field_data['multiselect_field'], ['bar', 'baz']) + def test_create_single_object_with_defaults(self): """ Create a new site with no specified custom field values and check that it received the default values. @@ -1373,8 +1662,8 @@ class CustomFieldAPITestCase(APITestCase): self.assertEqual(response_cf['datetime_field'].isoformat(), cf_defaults['datetime_field']) self.assertEqual(response_cf['url_field'], cf_defaults['url_field']) self.assertEqual(response_cf['json_field'], cf_defaults['json_field']) - self.assertEqual(response_cf['select_field'], cf_defaults['select_field']) - self.assertEqual(response_cf['multiselect_field'], cf_defaults['multiselect_field']) + self.assertEqual(response_cf['select_field'], self._select(cf_defaults['select_field'])) + self.assertEqual(response_cf['multiselect_field'], self._multiselect(cf_defaults['multiselect_field'])) self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field']) self.assertEqual( [obj['id'] for obj in response.data['custom_fields']['multiobject_field']], @@ -1438,8 +1727,8 @@ class CustomFieldAPITestCase(APITestCase): self.assertEqual(response_cf['datetime_field'], data_cf['datetime_field']) self.assertEqual(response_cf['url_field'], data_cf['url_field']) self.assertEqual(response_cf['json_field'], data_cf['json_field']) - self.assertEqual(response_cf['select_field'], data_cf['select_field']) - self.assertEqual(response_cf['multiselect_field'], data_cf['multiselect_field']) + self.assertEqual(response_cf['select_field'], self._select(data_cf['select_field'])) + self.assertEqual(response_cf['multiselect_field'], self._multiselect(data_cf['multiselect_field'])) self.assertEqual(response_cf['object_field']['id'], data_cf['object_field']) self.assertEqual( [obj['id'] for obj in response_cf['multiobject_field']], @@ -1504,8 +1793,8 @@ class CustomFieldAPITestCase(APITestCase): self.assertEqual(response_cf['datetime_field'].isoformat(), cf_defaults['datetime_field']) self.assertEqual(response_cf['url_field'], cf_defaults['url_field']) self.assertEqual(response_cf['json_field'], cf_defaults['json_field']) - self.assertEqual(response_cf['select_field'], cf_defaults['select_field']) - self.assertEqual(response_cf['multiselect_field'], cf_defaults['multiselect_field']) + self.assertEqual(response_cf['select_field'], self._select(cf_defaults['select_field'])) + self.assertEqual(response_cf['multiselect_field'], self._multiselect(cf_defaults['multiselect_field'])) self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field']) self.assertEqual( [obj['id'] for obj in response_cf['multiobject_field']], @@ -1584,8 +1873,11 @@ class CustomFieldAPITestCase(APITestCase): self.assertEqual(response_cf['datetime_field'], custom_field_data['datetime_field']) self.assertEqual(response_cf['url_field'], custom_field_data['url_field']) self.assertEqual(response_cf['json_field'], custom_field_data['json_field']) - self.assertEqual(response_cf['select_field'], custom_field_data['select_field']) - self.assertEqual(response_cf['multiselect_field'], custom_field_data['multiselect_field']) + self.assertEqual(response_cf['select_field'], self._select(custom_field_data['select_field'])) + self.assertEqual( + response_cf['multiselect_field'], + self._multiselect(custom_field_data['multiselect_field']) + ) self.assertEqual(response_cf['object_field']['id'], custom_field_data['object_field']) self.assertEqual( [obj['id'] for obj in response_cf['multiobject_field']], @@ -1638,8 +1930,8 @@ class CustomFieldAPITestCase(APITestCase): self.assertEqual(response_cf['datetime_field'], original_cfvs['datetime_field']) self.assertEqual(response_cf['url_field'], original_cfvs['url_field']) self.assertEqual(response_cf['json_field'], original_cfvs['json_field']) - self.assertEqual(response_cf['select_field'], original_cfvs['select_field']) - self.assertEqual(response_cf['multiselect_field'], original_cfvs['multiselect_field']) + self.assertEqual(response_cf['select_field'], self._select(original_cfvs['select_field'])) + self.assertEqual(response_cf['multiselect_field'], self._multiselect(original_cfvs['multiselect_field'])) self.assertEqual(response_cf['object_field']['id'], original_cfvs['object_field'].pk) self.assertListEqual( [obj['id'] for obj in response_cf['multiobject_field']], @@ -1869,6 +2161,38 @@ class CustomFieldAPITestCase(APITestCase): response = self.client.patch(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) + def test_url_scheme_validation(self): + """ + Test that URL custom field values must use a scheme permitted by ALLOWED_URL_SCHEMES (fixes + #22640), and that a schemeless value is normalized to an absolute URL (assume_scheme='https'), + consistent with the UI. + """ + site2 = Site.objects.get(name='Site 2') + url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) + self.add_permissions('dcim.change_site') + + # A dangerous scheme (e.g. javascript:) must be rejected + data = {'custom_fields': {'url_field': 'javascript:alert(1)'}} + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + + # A well-formed URL using a scheme outside ALLOWED_URL_SCHEMES must be rejected + data = {'custom_fields': {'url_field': 'gopher://example.com'}} + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + + # An allowed scheme must be accepted + data = {'custom_fields': {'url_field': 'https://example.com'}} + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + + # A schemeless value must be accepted and normalized to https, matching the UI + data = {'custom_fields': {'url_field': 'example.com'}} + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + site2.refresh_from_db() + self.assertEqual(site2.custom_field_data['url_field'], 'https://example.com') + def test_json_schema_validation(self): site2 = Site.objects.get(name='Site 2') url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) @@ -2535,3 +2859,1100 @@ class CustomFieldModelFilterTestCase(TestCase): 3 ) self.assertEqual(self.filterset({'cf_cf12__empty': True}, self.queryset).qs.count(), 1) + + +@contextmanager +def hold_data_lock(custom_field): + """ + Hold a custom field's data lock on a connection of its own, as a running background job does. + + A separate connection is what makes the lock observable: it is held for the duration of a job, + which spans many transactions, so a test cannot take it on the connection it is testing. + """ + lock_key = CustomField.data_lock_key(custom_field.pk) + connection = connections.create_connection(DEFAULT_DB_ALIAS) + try: + with connection.cursor() as cursor: + cursor.execute('SELECT pg_try_advisory_lock(%s, %s)', lock_key) + if not cursor.fetchone()[0]: + raise RuntimeError(f"Failed to acquire the data lock for {custom_field}") + yield + finally: + # Closing the session releases any advisory lock held on it + connection.close() + + +@override_settings(BULK_UPDATE_CHUNK_SIZE=1) +class DeferredCustomFieldDataTestCase(TestCase): + """ + Where too many objects are affected to update within the request, provisioning and purging + custom field data is handed to a background job and the field is not live until it completes. + + BULK_UPDATE_CHUNK_SIZE (which doubles as the threshold for deferral) is overridden down so that + the two objects below force the deferred path. It cannot be set to zero, which the setting + rejects, and which would also empty every batch so that the jobs updated nothing. + """ + @classmethod + def setUpTestData(cls): + Site.objects.bulk_create([ + Site(name='Site A', slug='site-a'), + Site(name='Site B', slug='site-b'), + ]) + cls.object_type = ObjectType.objects.get_for_model(Site) + + def create_field(self, name='field1', **kwargs): + cf = CustomField.objects.create(name=name, type=CustomFieldTypeChoices.TYPE_TEXT, **kwargs) + cf.object_types.set([self.object_type]) + cf.refresh_from_db() + return cf + + # + # Provisioning + # + + def test_provisioning_is_deferred(self): + cf = self.create_field(default='foo') + + self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_PROVISIONING) + # No object data has been written yet + self.assertEqual(Site.objects.filter(custom_field_data__has_key='field1').count(), 0) + + def test_field_is_not_live_while_provisioning(self): + cf = self.create_field(default='foo') + site = Site.objects.first() + + self.assertNotIn(cf, CustomField.objects.get_for_model(Site)) + self.assertNotIn('field1', site.cf) + self.assertNotIn('field1', {f.name for f in site.get_custom_fields()}) + + # It is still reachable where a caller asks for that status, as get_defaults_for_model() does + self.assertIn(cf, CustomField.objects.get_for_model( + Site, statuses=(CustomFieldStatusChoices.STATUS_PROVISIONING,) + )) + + def test_new_objects_receive_default_while_provisioning(self): + """ + A field is provisioned precisely because it carries a default, so an object created while + the backfill runs must still receive that default -- the job backfills only what predates + the field. + """ + self.create_field(default='foo') + + site = Site.objects.create(name='Site C', slug='site-c') + + site.refresh_from_db() + self.assertEqual(site.custom_field_data['field1'], 'foo') + + def test_stored_data_survives_validation_while_provisioning(self): + """ + A field which is not live is not validated, and neither is its stored data pruned as stale. + Saving an object through full_clean() -- as the edit form, the REST API and bulk edit all do + -- while the backfill runs must leave the stored value alone rather than reverting it to the + field's default. + """ + self.create_field(default='foo') + site = Site.objects.first() + # Written via the queryset so that the setup does not itself depend on save() + Site.objects.filter(pk=site.pk).update(custom_field_data={'field1': 'bar'}) + site.refresh_from_db() + + site.full_clean() + site.save() + + site.refresh_from_db() + self.assertEqual(site.custom_field_data['field1'], 'bar') + + def test_required_field_is_not_enforced_while_provisioning(self): + """ + The field is not live, so an object which the backfill has yet to reach must still validate. + """ + self.create_field(default='foo', required=True) + site = Site.objects.first() + self.assertNotIn('field1', site.custom_field_data) + + site.full_clean() # Must not raise + + def test_provisioning_job_backfills_and_activates(self): + cf = self.create_field(default='foo') + + self.assertTrue(provision_custom_field(cf.pk, [self.object_type.pk])) + + cf.refresh_from_db() + self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_ACTIVE) + self.assertEqual(Site.objects.filter(custom_field_data__field1='foo').count(), 2) + self.assertIn(cf, CustomField.objects.get_for_model(Site)) + + def test_provisioning_job_commits_each_batch(self): + """ + One transaction spanning the whole backfill would hold a row lock on every object it had + rewritten until it finished, for as long as CUSTOMFIELD_JOB_TIMEOUT allows the job to run + (see CustomField._update_object_data()). + """ + cf = self.create_field(default='foo') + + with patch.object(CustomField, '_update_object_data') as update: + provision_custom_field(cf.pk, [self.object_type.pk]) + + update.assert_called() + for call in update.call_args_list: + self.assertTrue(call.kwargs['commit_per_batch']) + + def test_provisioning_job_does_not_activate_a_field_marked_for_deletion(self): + """ + The field's status is rechecked as it is brought live, so that a deletion which landed while + the backfill ran -- as one could were the job's lock lost with its connection -- is not + undone by it. + """ + cf = self.create_field(default='foo') + + def mark_deleting(*args, **kwargs): + CustomField.objects.filter(pk=cf.pk).update(status=CustomFieldStatusChoices.STATUS_DELETING) + + with patch.object(CustomField, 'populate_initial_data', side_effect=mark_deleting): + self.assertFalse(provision_custom_field(cf.pk, [self.object_type.pk])) + + cf.refresh_from_db() + self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_DELETING) + + def test_provisioning_job_is_idempotent(self): + cf = self.create_field(default='foo') + provision_custom_field(cf.pk, [self.object_type.pk]) + + # A second run finds the field no longer awaiting provisioning and does nothing + self.assertFalse(provision_custom_field(cf.pk, [self.object_type.pk])) + + def test_provisioning_job_overrides_the_default_timeout(self): + """ + The job is enqueued precisely because the work exceeds what a request can absorb, so it must + not inherit RQ's default timeout, which is of the same order (see CUSTOMFIELD_JOB_TIMEOUT). + """ + with patch.object(CustomFieldProvisioningJob, 'enqueue') as enqueue: + with self.captureOnCommitCallbacks(execute=True): + cf = self.create_field(default='foo') + + enqueue.assert_called_once() + self.assertEqual(enqueue.call_args.kwargs['job_timeout'], CUSTOMFIELD_JOB_TIMEOUT) + self.assertEqual(enqueue.call_args.kwargs['custom_field_pk'], cf.pk) + + def test_provisioning_job_is_enqueued(self): + """ + The Job record itself must be valid: a custom field cannot be assigned to a Job as its + object, so the field is identified by primary key instead (see CustomFieldDataJob). + """ + with patch('core.models.jobs.django_rq') as django_rq: + with self.captureOnCommitCallbacks(execute=True): + cf = self.create_field(default='foo') + + job = Job.objects.get(name__startswith=CustomFieldProvisioningJob.name) + self.assertIsNone(job.object_type) + self.assertIn(str(cf), job.name) + self.assertEqual( + django_rq.get_queue.return_value.enqueue.call_args.kwargs['custom_field_pk'], cf.pk + ) + + def test_provisioning_job_forwards_its_object_types(self): + """ + Only the caller which deferred the work knows which assignments are the new ones, so the + types are carried by the job: run() has to pass them to the backfill rather than swallowing + them, or the field would go live having provisioned nothing. + """ + cf = self.create_field(default='foo') + + CustomFieldProvisioningJob(Job()).run(custom_field_pk=cf.pk, object_type_pks=[self.object_type.pk]) + + cf.refresh_from_db() + self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_ACTIVE) + self.assertEqual(Site.objects.filter(custom_field_data__field1='foo').count(), 2) + + def test_provisioning_is_scoped_to_the_new_object_types(self): + """ + Assigning a further object type provisions only that type. The job cannot work this out for + itself once the assignment is made, so the types are carried to it. + """ + cf = self.create_field() + cf.default = 'foo' + cf.save() + rack_type = ObjectType.objects.get_for_model(Rack) + site = Site.objects.first() + Rack.objects.bulk_create([ + Rack(name='Rack 1', site=site), + Rack(name='Rack 2', site=site), + ]) + + with patch.object(CustomFieldProvisioningJob, 'enqueue') as enqueue: + with self.captureOnCommitCallbacks(execute=True): + cf.object_types.add(rack_type) + + enqueue.assert_called_once() + self.assertEqual(enqueue.call_args.kwargs['object_type_pks'], [rack_type.pk]) + + def test_deferral_weighs_only_the_new_object_types(self): + """ + A field already assigned to a large table stays inline when assigned a small one: the tables + provisioned previously are not rewritten, so their size is beside the point. + """ + cf = self.create_field() + cf.default = 'foo' + cf.save() + + # No racks exist, so there is nothing to defer even though the two sites exceed the limit + cf.object_types.add(ObjectType.objects.get_for_model(Rack)) + + cf.refresh_from_db() + self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_ACTIVE) + + def test_provisioning_preserves_stored_values(self): + """ + An object which already holds a value for the field is left as it is: the backfill supplies + the default only where no value has been recorded. + """ + cf = self.create_field(default='foo') + Site.objects.update(custom_field_data={'field1': 'bar'}) + + self.assertTrue(provision_custom_field(cf.pk, [self.object_type.pk])) + + cf.refresh_from_db() + self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_ACTIVE) + self.assertEqual(Site.objects.filter(custom_field_data__field1='bar').count(), 2) + + def test_field_without_default_is_not_deferred(self): + """ + A field with no default has nothing to provision, so it goes live immediately regardless of + how many objects it applies to. + """ + cf = self.create_field() + + self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_ACTIVE) + + def test_field_without_default_enqueues_nothing(self): + """ + The decision rests with provision_data() rather than its caller, so a field with no default + must not reach the point of sizing its object types, let alone of handing a job the no-op of + writing a null to each of them. + """ + cf = self.create_field() + + with ( + patch.object(CustomField, '_exceeds_inline_limit') as exceeds_limit, + patch.object(CustomFieldProvisioningJob, 'enqueue') as enqueue, + ): + with self.captureOnCommitCallbacks(execute=True): + cf.provision_data([self.object_type]) + + exceeds_limit.assert_not_called() + enqueue.assert_not_called() + + def test_assignment_is_refused_while_provisioning(self): + """ + A second deferral would hand its job only the object types newly assigned, and whichever of + the two jobs ran first would bring the field live -- leaving the other to find a field it no + longer matched, and its own types unprovisioned. The assignment is refused instead, as every + other change to a field which is not live is (see CustomField.clean()). + """ + cf = self.create_field(default='foo') + rack_type = ObjectType.objects.get_for_model(Rack) + + with patch.object(CustomFieldProvisioningJob, 'enqueue') as enqueue: + with self.assertRaises(AbortRequest): + # Contained in a savepoint: the assignment is rolled back by the refusal, which + # would otherwise leave the test's own transaction needing one + with transaction.atomic(): + cf.object_types.add(rack_type) + + enqueue.assert_not_called() + cf.refresh_from_db() + self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_PROVISIONING) + self.assertNotIn(rack_type.pk, cf.object_types.values_list('pk', flat=True)) + + def test_assignment_is_refused_while_deleting(self): + """ + The refusal precedes the check for a default: a field whose data is being purged must not + take on further object types either, whether or not it has anything to provision on them. + """ + cf = self.create_field() + cf.delete() + rack_type = ObjectType.objects.get_for_model(Rack) + + with self.assertRaises(AbortRequest): + with transaction.atomic(): + cf.object_types.add(rack_type) + + cf.refresh_from_db() + self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_DELETING) + self.assertNotIn(rack_type.pk, cf.object_types.values_list('pk', flat=True)) + + def test_assignment_is_refused_against_the_stored_status(self): + """ + The status is read from the database rather than taken from the instance in hand, which a + job may have taken offline (or brought live) since it was fetched. + """ + cf = self.create_field() + + # Marked directly, leaving the instance in hand still reporting the field as live + CustomField.objects.filter(pk=cf.pk).update(status=CustomFieldStatusChoices.STATUS_PROVISIONING) + self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_ACTIVE) + + with self.assertRaises(AbortRequest): + with transaction.atomic(): + cf.object_types.add(ObjectType.objects.get_for_model(Rack)) + + def test_assignment_to_a_removed_field_is_refused(self): + """ + The row may be gone by the time the status is read under its lock, the field having been + deleted since the instance in hand was fetched. The assignment is reported as refused rather + than failing on the absent status. + """ + cf = self.create_field() + rack_type = ObjectType.objects.get_for_model(Rack) + + # Removed directly, leaving the instance in hand still reporting the field as live + CustomField.objects.filter(pk=cf.pk).delete() + self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_ACTIVE) + + with self.assertRaises(AbortRequest): + with transaction.atomic(): + cf.provision_data([rack_type]) + + def test_unassignment_is_refused_while_provisioning(self): + """ + A field being provisioned must not be unassigned from an object type either: the job carries + the object types it was given, and would write its defaults into objects behind the removal. + """ + cf = self.create_field(default='foo') + self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_PROVISIONING) + + with self.assertRaises(AbortRequest): + with transaction.atomic(): + cf.object_types.remove(self.object_type) + + self.assertIn(self.object_type.pk, cf.object_types.values_list('pk', flat=True)) + + def test_unassignment_is_refused_while_deleting(self): + cf = self.create_field() + cf.delete() + + with self.assertRaises(AbortRequest): + with transaction.atomic(): + cf.object_types.remove(self.object_type) + + self.assertIn(self.object_type.pk, cf.object_types.values_list('pk', flat=True)) + + def test_clearing_object_types_is_refused_while_provisioning(self): + """ + clear() is handled ahead of the removal rather than after it, as remove() is, so the refusal + reaches the caller with the assignments still in place. + """ + cf = self.create_field(default='foo') + + with self.assertRaises(AbortRequest): + with transaction.atomic(): + cf.object_types.clear() + + self.assertIn(self.object_type.pk, cf.object_types.values_list('pk', flat=True)) + + def test_unassignment_is_refused_against_the_stored_status(self): + """ + The status is read from the database rather than taken from the instance in hand, which a + job may have taken offline since it was fetched. + """ + cf = self.create_field() + + # Marked directly, leaving the instance in hand still reporting the field as live + CustomField.objects.filter(pk=cf.pk).update(status=CustomFieldStatusChoices.STATUS_PROVISIONING) + self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_ACTIVE) + + with self.assertRaises(AbortRequest): + with transaction.atomic(): + cf.object_types.remove(self.object_type) + + def test_unassignment_is_permitted_once_the_field_is_live(self): + cf = self.create_field(default='foo') + Site.objects.update(custom_field_data={'field1': 'foo'}) + self.assertTrue(provision_custom_field(cf.pk, [self.object_type.pk])) + cf.refresh_from_db() + + cf.object_types.remove(self.object_type) # Must not raise + + self.assertEqual(Site.objects.filter(custom_field_data__has_key='field1').count(), 0) + + def test_provisioning_job_skips_object_types_since_unassigned(self): + """ + The job provisions only those of its object types which the field still carries. remove_data() + refuses an unassignment while the field is being provisioned, so this covers a change made + through the m2m table directly, which emits no signal for either to act on. + """ + cf = self.create_field(default='foo') + rack_type = ObjectType.objects.get_for_model(Rack) + object_type_pks = [self.object_type.pk, rack_type.pk] + + # Unassigned without the signal handler's involvement + CustomField.object_types.through.objects.filter( + customfield=cf, contenttype=self.object_type + ).delete() + + self.assertTrue(provision_custom_field(cf.pk, object_type_pks)) + + self.assertEqual(Site.objects.filter(custom_field_data__has_key='field1').count(), 0) + + def test_assignment_is_permitted_once_the_field_is_live(self): + """ + The refusal lasts only as long as the pending update: once the job has brought the field + live, further object types are assigned as usual. + """ + cf = self.create_field(default='foo') + self.assertTrue(provision_custom_field(cf.pk, [self.object_type.pk])) + rack_type = ObjectType.objects.get_for_model(Rack) + cf.refresh_from_db() + + cf.object_types.add(rack_type) # Must not raise + + self.assertIn(rack_type.pk, cf.object_types.values_list('pk', flat=True)) + + # + # Request cache + # + + def test_taking_a_field_offline_clears_the_cached_fields(self): + """ + The fields cached for a request span the whole of it -- and the whole of a script or job run + -- so a field taken offline partway through one must not be served from what was cached + before it. + """ + token = query_cache.set(defaultdict(dict)) + self.addCleanup(query_cache.reset, token) + + CustomField.objects.get_for_model(Site) # Prime the cache while no field is assigned + + cf = self.create_field(default='foo') + self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_PROVISIONING) + + # Not live, but its default still reaches the objects created while it is provisioned + self.assertEqual(CustomField.objects.get_for_model(Site), []) + self.assertEqual(CustomField.objects.get_defaults_for_model(Site), {'field1': 'foo'}) + + def test_marking_a_field_for_deletion_clears_the_cached_fields(self): + cf = self.create_field() + + token = query_cache.set(defaultdict(dict)) + self.addCleanup(query_cache.reset, token) + + self.assertEqual([f.pk for f in CustomField.objects.get_for_model(Site)], [cf.pk]) + + cf.delete() + + self.assertEqual(CustomField.objects.get_for_model(Site), []) + + def test_bringing_a_field_live_clears_the_cached_fields(self): + cf = self.create_field(default='foo') + + token = query_cache.set(defaultdict(dict)) + self.addCleanup(query_cache.reset, token) + + self.assertEqual(CustomField.objects.get_for_model(Site), []) # Not live while provisioning + + self.assertTrue(provision_custom_field(cf.pk, [self.object_type.pk])) + + self.assertEqual([f.pk for f in CustomField.objects.get_for_model(Site)], [cf.pk]) + + # + # Deletion + # + + def test_deletion_is_deferred(self): + cf = self.create_field() + Site.objects.update(custom_field_data={'field1': 'foo'}) + + cf.delete() + + cf = CustomField.objects.get(pk=cf.pk) + self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_DELETING) + # The stored data is left for the purge job + self.assertEqual(Site.objects.filter(custom_field_data__has_key='field1').count(), 2) + + def test_deletion_is_deferred_even_without_stored_data(self): + """ + The deferral decision weighs every row of the assigned types, not just those which hold a + value, so a field holding no data on an over-limit table is still deferred. Deliberate: the + probe cannot count the rows holding a key without a sequential scan (see + _exceeds_inline_limit()), and the purge job it hands off to has nothing to do. + """ + cf = self.create_field() + self.assertEqual(Site.objects.filter(custom_field_data__has_key='field1').count(), 0) + + cf.delete() + + cf = CustomField.objects.get(pk=cf.pk) + self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_DELETING) + self.assertTrue(purge_custom_field(cf.pk)) + self.assertFalse(CustomField.objects.filter(pk=cf.pk).exists()) + + def test_field_is_not_live_while_deleting(self): + cf = self.create_field() + Site.objects.update(custom_field_data={'field1': 'foo'}) + cf.delete() + + site = Site.objects.first() + self.assertNotIn(cf, CustomField.objects.get_for_model(Site)) + self.assertNotIn('field1', site.cf) + self.assertNotIn('field1', {f.name for f in site.get_custom_fields()}) + + def test_stored_data_is_pruned_while_deleting(self): + """ + The converse of a field being provisioned: one on its way out has no claim on the data, so an + object saved before the purge job reaches it sheds the value as stale. + """ + cf = self.create_field() + Site.objects.update(custom_field_data={'field1': 'foo'}) + cf.delete() + + site = Site.objects.first() + site.full_clean() + site.save() + + site.refresh_from_db() + self.assertNotIn('field1', site.custom_field_data) + + def test_purge_job_removes_data_and_field(self): + cf = self.create_field() + Site.objects.update(custom_field_data={'field1': 'foo'}) + cf.delete() + + self.assertTrue(purge_custom_field(cf.pk)) + + self.assertFalse(CustomField.objects.filter(pk=cf.pk).exists()) + self.assertEqual(Site.objects.filter(custom_field_data__has_key='field1').count(), 0) + + def test_purge_job_commits_each_batch(self): + cf = self.create_field() + Site.objects.update(custom_field_data={'field1': 'foo'}) + cf.delete() + + with patch.object(CustomField, '_update_object_data') as update: + purge_custom_field(cf.pk) + + update.assert_called() + for call in update.call_args_list: + self.assertTrue(call.kwargs['commit_per_batch']) + + def test_purge_job_is_idempotent(self): + cf = self.create_field() + cf.delete() + purge_custom_field(cf.pk) + + # A second run finds the field already gone and does nothing + self.assertFalse(purge_custom_field(cf.pk)) + + def test_deleting_twice_does_not_repeat_the_deletion(self): + cf = self.create_field() + cf.delete() + + cf.delete() + + self.assertTrue(CustomField.objects.filter(pk=cf.pk).exists()) + + def test_deletion_is_decided_against_the_stored_status(self): + """ + The status is read from the database rather than taken from the instance in hand, which a + concurrent deletion may have marked since it was fetched. Acting on the stale copy would + dispatch the deletion signals a second time, recording a second change and firing the + deletion's event rules again for a field already gone. + """ + cf = self.create_field() + # Retained, as a deletion clears the primary key of the instance it was called on + pk = cf.pk + + # Marked directly, leaving the instance in hand still reporting the field as live + CustomField.objects.filter(pk=pk).update(status=CustomFieldStatusChoices.STATUS_DELETING) + self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_ACTIVE) + + request = RequestFactory().get('/') + request.id = uuid.uuid4() + request.user = self.user + with event_tracking(request): + deleted = cf.delete() + + self.assertEqual(deleted, (0, {})) + self.assertFalse( + ObjectChange.objects.filter( + changed_object_type=ObjectType.objects.get_for_model(CustomField), + changed_object_id=pk, + action=ObjectChangeActionChoices.ACTION_DELETE, + ).exists() + ) + + def test_deleting_a_field_already_removed_is_a_no_op(self): + """ + The row may be gone by the time the status is read under its lock, a concurrent request + having deleted the field outright. Nothing remains to delete, to report, or to purge. + """ + cf = self.create_field() + # Retained, as a deletion clears the primary key of the instance it was called on + pk = cf.pk + + # Removed directly, leaving the instance in hand still reporting the field as live + CustomField.objects.filter(pk=pk).delete() + + request = RequestFactory().get('/') + request.id = uuid.uuid4() + request.user = self.user + with patch.object(CustomFieldPurgeJob, 'enqueue') as enqueue: + with event_tracking(request), self.captureOnCommitCallbacks(execute=True): + deleted = cf.delete() + + self.assertEqual(deleted, (0, {})) + enqueue.assert_not_called() + # The deletion signals belong to the request which removed the row, not to this one + self.assertFalse( + ObjectChange.objects.filter( + changed_object_type=ObjectType.objects.get_for_model(CustomField), + changed_object_id=pk, + action=ObjectChangeActionChoices.ACTION_DELETE, + ).exists() + ) + + def test_deleting_a_field_already_pending_deletion_enqueues_a_fresh_purge_job(self): + """ + delete() is the only route to a purge job, so a field left pending deletion by a job which + never ran must be given another when its deletion is retried. It could not otherwise be + removed at all, and would hold its name against a replacement indefinitely. + """ + cf = self.create_field() + cf.delete() + + with patch.object(CustomFieldPurgeJob, 'enqueue') as enqueue: + with self.captureOnCommitCallbacks(execute=True): + cf.delete() + + enqueue.assert_called_once() + self.assertEqual(enqueue.call_args.kwargs['custom_field_pk'], cf.pk) + + def test_deleting_a_field_being_purged_is_refused(self): + """ + A running purge job needs no help, and a second job would only wait on its lock, occupying a + worker for as long as the first ran (see CUSTOMFIELD_JOB_TIMEOUT). The retry is refused as + the deletion of a live field would be: returning quietly would have the caller report a + deletion which did not happen, the field remaining exactly as it was. + """ + cf = self.create_field() + + # Marked directly rather than by delete(), which would take the lock on this connection and + # hold it for the remainder of the test transaction, leaving none for the job to hold + CustomField.objects.filter(pk=cf.pk).update(status=CustomFieldStatusChoices.STATUS_DELETING) + cf.refresh_from_db() + + with patch.object(CustomFieldPurgeJob, 'enqueue') as enqueue: + with hold_data_lock(cf): + with self.assertRaises(AbortRequest): + cf.delete() + + enqueue.assert_not_called() + cf.refresh_from_db() + self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_DELETING) + + def test_deleting_a_field_being_purged_is_reported_to_the_user(self): + """ + The refusal reaches the user as an error, rather than the unqualified success the delete view + reports for any deletion which does not raise. + """ + cf = self.create_field() + CustomField.objects.filter(pk=cf.pk).update(status=CustomFieldStatusChoices.STATUS_DELETING) + cf.refresh_from_db() + self.add_permissions('extras.view_customfield', 'extras.delete_customfield') + + with hold_data_lock(cf): + response = self.client.post( + reverse('extras:customfield_delete', kwargs={'pk': cf.pk}), + data={'confirm': True}, + follow=True, + ) + + self.assertEqual( + [str(m) for m in response.context['messages']], + [f"Custom field '{cf.name}' is being updated by a background job and cannot be deleted " + f"until that job has completed."] + ) + self.assertTrue(CustomField.objects.filter(pk=cf.pk).exists()) + + def test_aborted_deletion_leaves_the_field_intact(self): + """ + A receiver rejecting the deletion (e.g. handle_deleted_object() raising AbortRequest for a + failed protection rule) must leave the field live, rather than marked for a purge which + would destroy the very data the rule protected. + """ + cf = self.create_field() + Site.objects.update(custom_field_data={'field1': 'foo'}) + + def reject(sender, instance, **kwargs): + raise AbortRequest("Deletion is prevented by a protection rule") + + pre_delete.connect(reject, sender=CustomField) + try: + with self.assertRaises(AbortRequest): + cf.delete() + finally: + pre_delete.disconnect(reject, sender=CustomField) + + cf = CustomField.objects.get(pk=cf.pk) + self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_ACTIVE) + self.assertIn(cf, CustomField.objects.get_for_model(Site)) + self.assertEqual(Site.objects.filter(custom_field_data__field1='foo').count(), 2) + + def test_purge_job_overrides_the_default_timeout(self): + cf = self.create_field() + Site.objects.update(custom_field_data={'field1': 'foo'}) + + with patch.object(CustomFieldPurgeJob, 'enqueue') as enqueue: + with self.captureOnCommitCallbacks(execute=True): + cf.delete() + + enqueue.assert_called_once() + self.assertEqual(enqueue.call_args.kwargs['job_timeout'], CUSTOMFIELD_JOB_TIMEOUT) + self.assertEqual(enqueue.call_args.kwargs['custom_field_pk'], cf.pk) + + def test_purge_job_is_enqueued(self): + cf = self.create_field() + Site.objects.update(custom_field_data={'field1': 'foo'}) + + with patch('core.models.jobs.django_rq') as django_rq: + with self.captureOnCommitCallbacks(execute=True): + cf.delete() + + job = Job.objects.get(name__startswith=CustomFieldPurgeJob.name) + self.assertIsNone(job.object_type) + self.assertIn(str(cf), job.name) + self.assertEqual( + django_rq.get_queue.return_value.enqueue.call_args.kwargs['custom_field_pk'], cf.pk + ) + + def test_deletion_records_a_change(self): + """ + The change log must report the deletion where the user performed it, rather than when the + row is eventually removed in a worker (where there is no request to attribute it to). + """ + cf = self.create_field() + + request = RequestFactory().get('/') + request.id = uuid.uuid4() + request.user = self.user + with event_tracking(request): + cf.delete() + + self.assertTrue( + ObjectChange.objects.filter( + changed_object_type=ObjectType.objects.get_for_model(CustomField), + changed_object_id=cf.pk, + action=ObjectChangeActionChoices.ACTION_DELETE, + ).exists() + ) + + def test_deletion_is_scoped_to_the_write_database(self): + """ + The commit hook must be registered against the connection the marking was written on, or the + purge job can be enqueued before -- or without -- the field being durably marked. + """ + cf = self.create_field() + + with patch('extras.models.customfields.transaction.on_commit') as on_commit: + cf.delete() + + # transaction.on_commit is patched on the shared module, so hooks registered by unrelated + # machinery during the delete (deferred search indexing, for one) are captured here too. + # Select the hook which enqueues the purge job rather than assuming it is the only one. + calls = [ + call for call in on_commit.call_args_list + if 'CustomField.delete' in getattr(call.args[0], '__qualname__', '') + ] + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0].kwargs['using'], DEFAULT_DB_ALIAS) + + # + # Name reservation + # + + def test_name_is_reserved_while_deleting(self): + """ + A field pending deletion holds its name, so that a new field cannot inherit the values still + stored against it. + """ + cf = self.create_field() + Site.objects.update(custom_field_data={'field1': 'foo'}) + cf.delete() + + replacement = CustomField(name='field1', type=CustomFieldTypeChoices.TYPE_TEXT) + with self.assertRaises(ValidationError): + replacement.full_clean() + + def test_rename_onto_reserved_name_is_rejected(self): + cf = self.create_field() + Site.objects.update(custom_field_data={'field1': 'foo'}) + cf.delete() + other = self.create_field(name='field2') + + other.name = 'field1' + with self.assertRaises(ValidationError): + other.full_clean() + + def test_name_is_released_once_purged(self): + cf = self.create_field() + cf.delete() + purge_custom_field(cf.pk) + + replacement = CustomField(name='field1', type=CustomFieldTypeChoices.TYPE_TEXT) + replacement.full_clean() # Should not raise + + # + # Modification and deletion guards + # + + def test_pending_field_cannot_be_modified(self): + cf = self.create_field(default='foo') + + cf.label = 'Changed' + with self.assertRaises(ValidationError): + cf.full_clean() + + def test_deletion_claims_the_data_lock_without_waiting(self): + """ + A job holds the field's data lock for the duration of its bulk update, so a deletion which + waited on it would occupy a worker for as long as the job ran (see CUSTOMFIELD_JOB_TIMEOUT). + """ + cf = self.create_field() + + with CaptureQueriesContext(connection) as queries: + cf.delete() + + self.assertTrue( + any('pg_try_advisory_xact_lock' in query['sql'] for query in queries), + "Deletion did not claim the field's data lock without waiting" + ) + + def test_deletion_holds_the_data_lock_until_its_transaction_ends(self): + """ + Where the caller supplies its own transaction -- BulkDeleteView, and every REST API deletion + -- the field's new status is still uncommitted when delete() returns. Releasing the lock + there would let a provisioning job read the field as live and set it live again. + """ + cf = self.create_field() + + with transaction.atomic(): + cf.delete() + + # delete() has returned and its own atomic block has exited, but the enclosing transaction + # has yet to commit, so the lock must still be held + with self.assertRaises(RuntimeError): + with hold_data_lock(cf): + pass + + def test_deletion_is_refused_while_a_job_holds_the_data_lock(self): + """ + Failing to take the lock aborts the deletion cleanly, rather than surfacing a database error, + and must leave the field exactly as it was. + """ + cf = self.create_field() + + with hold_data_lock(cf): + with self.assertRaises(AbortRequest): + cf.delete() + + cf.refresh_from_db() + self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_ACTIVE) + + cf.delete() # Released: the deletion now proceeds + + cf.refresh_from_db() + self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_DELETING) + + def test_stranded_field_can_be_deleted(self): + """ + The refusal is on the lock, not on the status: a field left mid-provisioning by a job which + never ran holds no lock, and must remain deletable. + """ + cf = self.create_field(default='foo') + self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_PROVISIONING) + + cf.delete() + + cf.refresh_from_db() + self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_DELETING) + + +class InlineCustomFieldDataTestCase(TestCase): + """ + Where few enough objects are affected, provisioning and purging remain synchronous: the field is + live (or gone) as soon as the request completes, with no background job involved. + """ + @classmethod + def setUpTestData(cls): + Site.objects.create(name='Site A', slug='site-a') + cls.object_type = ObjectType.objects.get_for_model(Site) + + def test_provisioning_is_inline(self): + cf = CustomField.objects.create( + name='field1', type=CustomFieldTypeChoices.TYPE_TEXT, default='foo' + ) + cf.object_types.set([self.object_type]) + + cf.refresh_from_db() + self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_ACTIVE) + self.assertEqual(Site.objects.filter(custom_field_data__field1='foo').count(), 1) + + def test_inline_provisioning_is_atomic(self): + """ + The request path answers to an enclosing transaction, which owns the commit: committing each + batch there would silently do nothing, and a failure part-way must leave nothing behind. + """ + with patch.object(CustomField, '_update_object_data') as update: + cf = CustomField.objects.create( + name='field1', type=CustomFieldTypeChoices.TYPE_TEXT, default='foo' + ) + cf.object_types.set([self.object_type]) + + update.assert_called() + for call in update.call_args_list: + self.assertFalse(call.kwargs['commit_per_batch']) + + def test_default_added_later_is_not_backfilled(self): + """ + A default added to a field which already exists is not backfilled, and assigning a further + object type must not backfill it either: only the newly assigned type is provisioned. The + sites below would otherwise acquire a value they were documented never to receive. + """ + cf = CustomField.objects.create(name='field1', type=CustomFieldTypeChoices.TYPE_TEXT) + cf.object_types.set([self.object_type]) + self.assertEqual(Site.objects.first().custom_field_data, {}) + + cf.default = 'foo' + cf.save() + self.assertEqual(Site.objects.first().custom_field_data, {}) + + rack = Rack.objects.create(name='Rack 1', site=Site.objects.first()) + cf.object_types.add(ObjectType.objects.get_for_model(Rack)) + + # The newly assigned type is provisioned; the one assigned before the default is not + rack.refresh_from_db() + self.assertEqual(rack.custom_field_data['field1'], 'foo') + self.assertEqual(Site.objects.first().custom_field_data, {}) + + def test_provisioning_preserves_existing_values(self): + """ + Values stored against a type assigned previously must survive a further assignment. + """ + cf = CustomField.objects.create( + name='field1', type=CustomFieldTypeChoices.TYPE_TEXT, default='foo' + ) + cf.object_types.set([self.object_type]) + Site.objects.update(custom_field_data={'field1': 'bar'}) + + cf.object_types.add(ObjectType.objects.get_for_model(Rack)) + + self.assertEqual(Site.objects.first().custom_field_data['field1'], 'bar') + + def test_provisioning_preserves_cleared_values(self): + """ + A cleared value is stored as a JSON null rather than an absent key, and must survive + reprovisioning just as a set value does. + """ + cf = CustomField.objects.create( + name='field1', type=CustomFieldTypeChoices.TYPE_TEXT, default='foo' + ) + cf.object_types.set([self.object_type]) + Site.objects.update(custom_field_data={'field1': None}) + + cf.object_types.add(ObjectType.objects.get_for_model(Rack)) + + self.assertIsNone(Site.objects.first().custom_field_data['field1']) + + def test_deletion_is_inline(self): + cf = CustomField.objects.create(name='field1', type=CustomFieldTypeChoices.TYPE_TEXT) + cf.object_types.set([self.object_type]) + Site.objects.update(custom_field_data={'field1': 'foo'}) + + cf.delete() + + self.assertFalse(CustomField.objects.filter(pk=cf.pk).exists()) + self.assertEqual(Site.objects.filter(custom_field_data__has_key='field1').count(), 0) + + +@override_settings(BULK_UPDATE_CHUNK_SIZE=None) +class UnchunkedCustomFieldDataTestCase(TestCase): + """ + Setting BULK_UPDATE_CHUNK_SIZE to None disables chunking, so a bulk update is issued as a single + unbounded statement. There is then no batch size for the deferral threshold to test against, and + an unbounded JSONB rewrite is exactly what must not run inside a request -- so any affected + object sends the work to a background job, which issues that one statement under a timeout + generous enough to survive it. + """ + @classmethod + def setUpTestData(cls): + Site.objects.create(name='Site A', slug='site-a') + cls.object_type = ObjectType.objects.get_for_model(Site) + + @staticmethod + def _count_updates(queries, model): + """ + Count the UPDATE statements issued against the given model's table, ignoring those the job + makes to the custom field row itself (marking it active). + """ + table = model._meta.db_table + return len([ + q for q in queries + if q['sql'].strip().upper().startswith('UPDATE') and table in q['sql'] + ]) + + def test_provisioning_is_deferred(self): + """ + A single object is enough: with chunking disabled there is no bound on the statement the + request would otherwise issue. + """ + cf = CustomField.objects.create( + name='field1', type=CustomFieldTypeChoices.TYPE_TEXT, default='foo' + ) + cf.object_types.set([self.object_type]) + + cf.refresh_from_db() + self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_PROVISIONING) + self.assertEqual(Site.objects.filter(custom_field_data__has_key='field1').count(), 0) + + def test_provisioning_job_backfills_in_a_single_statement(self): + cf = CustomField.objects.create( + name='field1', type=CustomFieldTypeChoices.TYPE_TEXT, default='foo' + ) + cf.object_types.set([self.object_type]) + + with CaptureQueriesContext(connection) as queries: + self.assertTrue(provision_custom_field(cf.pk, [self.object_type.pk])) + + # One statement covers the table, rather than one per batch + self.assertEqual(self._count_updates(queries.captured_queries, Site), 1) + + cf.refresh_from_db() + self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_ACTIVE) + self.assertEqual(Site.objects.filter(custom_field_data__field1='foo').count(), 1) + + def test_field_affecting_no_objects_stays_inline(self): + """ + A limit of zero still leaves the probe testing for a single row, so a field which rewrites + nothing goes live in the request rather than waiting on a job with no work to do. + """ + cf = CustomField.objects.create( + name='field1', type=CustomFieldTypeChoices.TYPE_TEXT, default='foo' + ) + + # No racks exist, so there is nothing to rewrite + cf.object_types.set([ObjectType.objects.get_for_model(Rack)]) + + cf.refresh_from_db() + self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_ACTIVE) + + def test_deletion_is_deferred(self): + cf = CustomField.objects.create(name='field1', type=CustomFieldTypeChoices.TYPE_TEXT) + cf.object_types.set([self.object_type]) + Site.objects.update(custom_field_data={'field1': 'foo'}) + + cf.delete() + + cf.refresh_from_db() + self.assertEqual(cf.status, CustomFieldStatusChoices.STATUS_DELETING) + self.assertTrue(purge_custom_field(cf.pk)) + self.assertFalse(CustomField.objects.filter(pk=cf.pk).exists()) + self.assertEqual(Site.objects.filter(custom_field_data__has_key='field1').count(), 0) diff --git a/netbox/extras/tests/test_event_rules.py b/netbox/extras/tests/test_event_rules.py index 224c231a9..89bf1035d 100644 --- a/netbox/extras/tests/test_event_rules.py +++ b/netbox/extras/tests/test_event_rules.py @@ -6,7 +6,9 @@ from unittest import skipIf from unittest.mock import Mock, PropertyMock, patch import django_rq +import requests from django.conf import settings +from django.core.exceptions import ImproperlyConfigured, ValidationError from django.http import HttpResponse from django.test import RequestFactory, TestCase, override_settings, tag from django.urls import reverse @@ -20,7 +22,7 @@ from core.models import Job, ObjectType from dcim.choices import DeviceStatusChoices, InterfaceTypeChoices, SiteStatusChoices from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site from extras.choices import EventRuleActionChoices -from extras.events import enqueue_event, flush_events, serialize_for_event +from extras.events import enqueue_event, flush_events, process_event_rules, serialize_for_event from extras.models import EventRule, Notification, Script, ScriptModule, Tag, Webhook from extras.scripts import Script as ScriptBase from extras.signals import process_job_end_event_rules @@ -28,6 +30,14 @@ from extras.webhooks import generate_signature, send_webhook from ipam.choices import IPAddressStatusChoices from ipam.models import IPAddress, Prefix from netbox.context_managers import event_tracking +from netbox.event_rules import ( + EventRuleAction, + get_event_rule_action, + get_event_rule_action_choices, + register_event_rule_action, +) +from netbox.registry import registry +from netbox.tests.dummy_plugin.event_rules import DummyRaisingAction from users.models import ObjectPermission from utilities.testing import APITestCase, create_test_device, disable_warnings from utilities.testing.mixins import RQQueueTestMixin @@ -107,6 +117,47 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase): Tag(name='Baz', slug='baz'), )) + def test_eventrule_snapshot_changed_condition(self): + """ + An event rule using the 'changed' operator fires only when the attribute + transitions to the target value, not on subsequent updates that leave it + unchanged. Exercises the full process_event_rules() path. + """ + webhook = Webhook.objects.get(name='Webhook 1') + webhook_type = ObjectType.objects.get_for_model(Webhook) + site_type = ObjectType.objects.get_for_model(Site) + event_rule = EventRule.objects.create( + name='Status Change Rule', + event_types=[OBJECT_UPDATED], + action_type=EventRuleActionChoices.WEBHOOK, + action_object_type=webhook_type, + action_object_id=webhook.pk, + conditions={ + 'and': [ + {'attr': 'status.value', 'value': SiteStatusChoices.STATUS_ACTIVE}, + {'attr': 'status', 'op': 'changed'}, + ] + } + ) + event_rule.object_types.set([site_type]) + + site = Site.objects.create(name='Site Snapshot', slug='site-snapshot', status=SiteStatusChoices.STATUS_PLANNED) + url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk}) + self.add_permissions('dcim.change_site') + + # planned → active: the 'changed' condition is satisfied; rule must fire + response = self.client.patch(url, {'status': SiteStatusChoices.STATUS_ACTIVE}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + rule_jobs = [j for j in self.queue.jobs if j.kwargs['event_rule'] == event_rule] + self.assertEqual(len(rule_jobs), 1, 'Expected rule to fire on status transition to active') + self.queue.empty() + + # description update while status stays active: 'changed' condition fails; rule must not fire + response = self.client.patch(url, {'description': 'Updated'}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + rule_jobs = [j for j in self.queue.jobs if j.kwargs['event_rule'] == event_rule] + self.assertEqual(len(rule_jobs), 0, 'Expected rule not to fire when status is unchanged') + def test_eventrule_conditions(self): """ Test evaluation of EventRule conditions. @@ -257,7 +308,7 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase): role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') site = Site.objects.create(name='Site 1', slug='site-1') - # DeviceViewSet uses SequentialBulkCreatesMixin, so each valid object is provisionally + # Bulk creates are performed one object at a time, so each valid object is provisionally # created (and its event queued) before a later object fails validation. event_rule = EventRule.objects.get(name='Event Rule 1') event_rule.object_types.set([ObjectType.objects.get_for_model(Device)]) @@ -582,9 +633,9 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase): def test_bulk_delete_abort_discards_events(self): """ - Check that a bulk delete aborted by an exception (rather than by a per-object error) also - queues no background tasks. A protection rule raises AbortRequest from a signal receiver, - which propagates out of the per-object loop. + Check that a bulk delete blocked by a signal receiver raising AbortRequest (rather than by a + database constraint) also queues no background tasks for the objects that were provisionally + deleted before the failure. """ sites = ( Site(name='Site 1', slug='site-1', description='Has a description'), @@ -600,9 +651,14 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase): protection_rules = {'dcim.site': [{'description': {'required': True}}]} with override_settings(PROTECTION_RULES=protection_rules): response = self.client.delete(url, data, format='json', **self.header) + # 400 rather than 409: a protection rule rejects the request, it is not a conflict with a + # dependent object (see BulkDestroyModelMixin.bulk_destroy) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) self.assertEqual(Site.objects.count(), 2) + # The failure is correlated to the blocked object only + self.assertEqual([e['id'] for e in response.data['errors']], [sites[1].pk]) + # No task may be queued for a deletion that was rolled back self.assertEqual(self.queue.count, 0) @@ -625,13 +681,14 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase): self.assertEqual(request.headers['X-Hook-Signature'], signature) self.assertEqual(request.headers['X-Foo'], 'Bar') + # The webhook does not define its own timeout, so the global default should be used + self.assertEqual(kwargs['timeout'], settings.WEBHOOK_DEFAULT_TIMEOUT) + # Validate the outgoing request body body = json.loads(request.body) self.assertEqual(body['event'], 'created') self.assertEqual(body['timestamp'], job.kwargs['timestamp']) self.assertEqual(body['object_type'], 'dcim.site') - self.assertEqual(body['username'], 'testuser') - self.assertEqual(body['request_id'], str(request_id)) self.assertEqual(body['data']['name'], 'Site 1') self.assertEqual(body['data']['foo'], 1) self.assertEqual(body['context']['foo'], 123) # From netbox.tests.dummy_plugin @@ -665,13 +722,109 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase): with patch.object(Session, 'send', dummy_send): send_webhook(**job.kwargs) - def test_job_completed_webhook_username_fallback(self): + def test_send_webhook_per_webhook_timeout(self): + """ + A webhook which defines its own timeout should use that value in preference to the + global WEBHOOK_DEFAULT_TIMEOUT. + """ + webhook = Webhook.objects.get(name='Webhook 1') + webhook.timeout = 5 + webhook.save() + + def dummy_send(_, request, **kwargs): + self.assertEqual(kwargs['timeout'], 5) + return HttpResponse() + + request = RequestFactory().get(reverse('dcim:site_add')) + request.id = uuid.uuid4() + request.user = self.user + + webhooks_queue = {} + site = Site.objects.create(name='Site 1', slug='site-1') + enqueue_event( + webhooks_queue, + instance=site, + request=request, + event_type=OBJECT_CREATED, + ) + flush_events(list(webhooks_queue.values())) + + job = self.queue.jobs[0] + with patch.object(Session, 'send', dummy_send): + send_webhook(**job.kwargs) + + @override_settings(RQ_DEFAULT_TIMEOUT=10) + def test_send_webhook_timeout_exceeding_job_timeout_is_logged(self): + """ + A timeout which meets or exceeds the background job timeout should be logged as a warning. This can + occur when RQ_DEFAULT_TIMEOUT has been lowered after the webhook was saved, which Webhook.clean() + cannot catch. + """ + webhook = Webhook.objects.get(name='Webhook 1') + webhook.timeout = 30 + webhook.save() + + request = RequestFactory().get(reverse('dcim:site_add')) + request.id = uuid.uuid4() + request.user = self.user + + webhooks_queue = {} + site = Site.objects.create(name='Site 1', slug='site-1') + enqueue_event( + webhooks_queue, + instance=site, + request=request, + event_type=OBJECT_CREATED, + ) + flush_events(list(webhooks_queue.values())) + + job = self.queue.jobs[0] + with patch.object(Session, 'send', lambda _, request, **kwargs: HttpResponse()): + with self.assertLogs('netbox.webhooks', level='WARNING') as cm: + send_webhook(**job.kwargs) + + self.assertIn( + 'Webhook timeout (30 seconds) is not less than the background job timeout (10 seconds)', + '\n'.join(cm.output) + ) + + def test_send_webhook_timeout_is_logged(self): + """ + A request which times out should be logged as an error before the exception is re-raised, so that the + failure is discoverable without resorting to the RQ worker's traceback. + """ + def timing_out_send(_, request, **kwargs): + raise requests.exceptions.ConnectTimeout('Connection timed out') + + request = RequestFactory().get(reverse('dcim:site_add')) + request.id = uuid.uuid4() + request.user = self.user + + webhooks_queue = {} + site = Site.objects.create(name='Site 1', slug='site-1') + enqueue_event( + webhooks_queue, + instance=site, + request=request, + event_type=OBJECT_CREATED, + ) + flush_events(list(webhooks_queue.values())) + + job = self.queue.jobs[0] + with patch.object(Session, 'send', timing_out_send): + with self.assertLogs('netbox.webhooks', level='ERROR') as cm: + with self.assertRaises(requests.exceptions.Timeout): + send_webhook(**job.kwargs) + + self.assertIn(f'timed out after {settings.WEBHOOK_DEFAULT_TIMEOUT} seconds', cm.output[0]) + + def test_job_completed_webhook_without_request(self): """ Ensure job_end event processing can enqueue a webhook even when the EventContext - lacks legacy request attributes (e.g. `username`). + lacks a request context. The job_start/job_end signal receivers only populate `user` and `data`, so webhook - processing must derive the username from the user object (or tolerate it being unset). + processing must tolerate the absence of a request. """ script_type = ObjectType.objects.get_for_model(Script) webhook_type = ObjectType.objects.get_for_model(Webhook) @@ -693,7 +846,123 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase): self.assertEqual(job.kwargs['event_rule'], event_rule) self.assertEqual(job.kwargs['event_type'], JOB_COMPLETED) self.assertEqual(job.kwargs['object_type'], script_type) - self.assertEqual(job.kwargs['username'], self.user.username) + self.assertNotIn('request', job.kwargs) + + def _job_event_rule(self, conditions=None): + webhook = Webhook.objects.get(name='Webhook 1') + event_rule = EventRule.objects.create( + name='Event Rule Job Completed', + event_types=[JOB_COMPLETED], + action_type=EventRuleActionChoices.WEBHOOK, + action_object_type=ObjectType.objects.get_for_model(Webhook), + action_object_id=webhook.pk, + conditions=conditions, + ) + event_rule.object_types.set([ObjectType.objects.get_for_model(Script)]) + return event_rule + + def test_job_event_with_null_data(self): + """ + Job.data is nullable, and a job which recorded no data is entirely routine. Event + processing must handle it rather than raising while merging the payload. + """ + script_type = ObjectType.objects.get_for_model(Script) + self._job_event_rule() + process_job_end_event_rules(Mock(object_type=script_type, data=None, user=self.user)) + self.assertEqual(self.queue.count, 1) + self.assertEqual(self.queue.jobs[0].kwargs['data'], {}) + + def test_job_event_with_null_data_and_conditions(self): + """ + A condition referencing an attribute of a null payload is a non-match rather than an + error: the rule is skipped without logging, since a job which recorded no data is + routine rather than a misconfigured rule. + """ + script_type = ObjectType.objects.get_for_model(Script) + self._job_event_rule(conditions={'attr': 'status', 'value': 'completed'}) + with self.assertNoLogs('netbox.event_rules', level='ERROR'): + process_job_end_event_rules(Mock(object_type=script_type, data=None, user=self.user)) + self.assertEqual(self.queue.count, 0) + + def test_job_event_with_null_data_does_not_satisfy_conditions(self): + """ + A null payload must not satisfy a conditioned rule, however the condition is phrased: + there is no data to evaluate, so nothing may enqueue the rule's action. A test for null + and a negated test are the two phrasings which would otherwise match. + """ + script_type = ObjectType.objects.get_for_model(Script) + for conditions in ( + {'attr': 'status', 'value': None}, + {'attr': 'status', 'value': 'completed', 'negate': True}, + ): + with self.subTest(conditions=conditions): + event_rule = self._job_event_rule(conditions=conditions) + with self.assertNoLogs('netbox.event_rules', level='ERROR'): + process_job_end_event_rules(Mock(object_type=script_type, data=None, user=self.user)) + self.assertEqual(self.queue.count, 0) + event_rule.delete() + + def test_job_event_with_non_dict_data(self): + """ + A payload which is neither null nor a dict is unexpected: log it, but continue + processing rather than aborting the batch. + """ + script_type = ObjectType.objects.get_for_model(Script) + self._job_event_rule() + for payload in ([1, 2], 'a string', 42): + self.queue.empty() + with self.assertLogs('netbox.events_processor', level='WARNING') as cm: + process_job_end_event_rules(Mock(object_type=script_type, data=payload, user=self.user)) + self.assertIn(type(payload).__name__, cm.output[0]) + self.assertEqual(self.queue.count, 1) + self.assertEqual(self.queue.jobs[0].kwargs['data'], {}) + + def test_job_event_with_non_dict_data_and_conditions(self): + """ + An invalid payload is no more evaluable than an absent one, so a conditioned rule must + fail closed for it — including for the phrasings which a payload normalized to an empty + dict would otherwise satisfy. The invalid payload is still reported once for the event, + as the anomaly it is. + """ + script_type = ObjectType.objects.get_for_model(Script) + for conditions in ( + {'attr': 'status', 'value': None}, + {'attr': 'status', 'value': 'completed', 'negate': True}, + {'attr': 'status', 'value': 'completed'}, + ): + event_rule = self._job_event_rule(conditions=conditions) + for payload in ([1, 2], 'a string', 42): + with self.subTest(conditions=conditions, payload=payload): + self.queue.empty() + with self.assertLogs('netbox.events_processor', level='WARNING') as cm: + process_job_end_event_rules( + Mock(object_type=script_type, data=payload, user=self.user) + ) + self.assertIn(type(payload).__name__, cm.output[0]) + self.assertEqual(self.queue.count, 0) + event_rule.delete() + + def test_no_matching_rules_leaves_payload_unserialized(self): + """ + Normalizing the payload must not defeat EventContext's lazy serialization: an + event with no applicable rules should never have its payload materialized. + """ + request = RequestFactory().get('/') + request.id = uuid.uuid4() + request.user = self.user + site = Site.objects.create(name='Site Lazy', slug='site-lazy') + + queue = {} + enqueue_event(queue, site, request, OBJECT_UPDATED) + event = queue[f'dcim.site:{site.pk}'] + self.assertNotIn('data', event.data) + + process_event_rules( + event_rules=EventRule.objects.none(), + object_type=ObjectType.objects.get_for_model(Site), + event=event, + ) + self.assertNotIn('data', event.data) def test_duplicate_enqueue_refreshes_lazy_payload(self): """ @@ -1178,6 +1447,464 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase): self.assertEqual(job.kwargs['event_rule'], event_rule) self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED) + def test_unregistered_action_type_does_not_block_other_rules(self): + """ + An unregistered action_type must not block other EventRules for the same event. Kept on + this class, not a separate RQQueueTestMixin one, since two such classes in different + `--parallel` subsuites cross-flush each other's Redis queue. + """ + site_type = ObjectType.objects.get_for_model(Site) + webhook = Webhook.objects.create(name='Dispatch Test Webhook', payload_url='http://localhost:9000/') + webhook_type = ObjectType.objects.get_for_model(Webhook) + + good_rule = EventRule.objects.create( + name='Good Rule', + event_types=[OBJECT_CREATED], + action_type=EventRuleActionChoices.WEBHOOK, + action_object_type=webhook_type, + action_object_id=webhook.pk, + ) + good_rule.object_types.set([site_type]) + + bad_rule = EventRule.objects.create( + name='Bad Rule', + event_types=[OBJECT_CREATED], + action_type='someplugin.not_installed', + ) + bad_rule.object_types.set([site_type]) + + url = reverse('dcim-api:site-list') + self.add_permissions('dcim.add_site') + with self.assertLogs('netbox.events_processor', level='WARNING') as cm: + response = self.client.post( + url, {'name': 'Dispatch Site', 'slug': 'dispatch-site'}, format='json', **self.header + ) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertTrue(any('someplugin.not_installed' in message for message in cm.output)) + + # The good rule's webhook must still have been enqueued despite the bad rule. + rule_jobs = [j for j in self.queue.jobs if j.kwargs['event_rule'] == good_rule] + self.assertEqual(len(rule_jobs), 1) + + def test_raising_enqueue_does_not_block_other_rules(self): + """A raising action registered as plugin-provided (the default) must not block other rules.""" + register_event_rule_action(DummyRaisingAction) + self.addCleanup(registry['event_rule_actions'].pop, DummyRaisingAction.slug, None) + + site_type = ObjectType.objects.get_for_model(Site) + webhook = Webhook.objects.create(name='Dispatch Test Webhook 2', payload_url='http://localhost:9000/') + webhook_type = ObjectType.objects.get_for_model(Webhook) + + good_rule = EventRule.objects.create( + name='Good Rule 2', + event_types=[OBJECT_CREATED], + action_type=EventRuleActionChoices.WEBHOOK, + action_object_type=webhook_type, + action_object_id=webhook.pk, + ) + good_rule.object_types.set([site_type]) + + raising_rule = EventRule.objects.create( + name='Raising Rule', + event_types=[OBJECT_CREATED], + action_type=DummyRaisingAction.slug, + ) + raising_rule.object_types.set([site_type]) + + url = reverse('dcim-api:site-list') + self.add_permissions('dcim.add_site') + with self.assertLogs('netbox.events_processor', level='ERROR') as cm: + response = self.client.post( + url, {'name': 'Dispatch Site 2', 'slug': 'dispatch-site-2'}, format='json', **self.header + ) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertTrue(any('Raising Rule' in message for message in cm.output)) + + # The good rule's webhook must still have been enqueued despite the raising rule. + rule_jobs = [j for j in self.queue.jobs if j.kwargs['event_rule'] == good_rule] + self.assertEqual(len(rule_jobs), 1) + + def test_raising_action_registered_as_non_plugin_propagates(self): + """A raising action registered with is_plugin_provided=False (as core actions are) must propagate.""" + class RaisingCoreLikeAction(EventRuleAction): + slug = 'test.raising_core_like_action' + label = 'Raising Core-Like Action' + object_required = False + + def enqueue(self, **kwargs): + raise RuntimeError("intentional failure for test") + + register_event_rule_action(RaisingCoreLikeAction, is_plugin_provided=False) + self.addCleanup(registry['event_rule_actions'].pop, 'test.raising_core_like_action', None) + + site_type = ObjectType.objects.get_for_model(Site) + rule = EventRule.objects.create( + name='Raising Core-Like Rule', + event_types=[OBJECT_CREATED], + action_type='test.raising_core_like_action', + ) + rule.object_types.set([site_type]) + + with self.assertRaises(RuntimeError): + process_event_rules([rule], object_type=site_type, event={'data': {}, 'event_type': OBJECT_CREATED}) + + +class EventRuleActionRegistrationTestCase(TestCase): + """ + Unit tests for the EventRuleAction registry (netbox.event_rules). + """ + + def tearDown(self): + super().tearDown() + # The registry is a global dict; test-registered actions must not leak into other tests. + for slug in ('test.dummy_action', 'test.duplicate_action'): + registry['event_rule_actions'].pop(slug, None) + + def test_register_event_rule_action(self): + class DummyAction(EventRuleAction): + slug = 'test.dummy_action' + label = 'Dummy Action' + description = 'A dummy action for testing' + + register_event_rule_action(DummyAction) + + action = get_event_rule_action('test.dummy_action') + self.assertIsInstance(action, DummyAction) + + choices = {choice.value: choice.label for choice in get_event_rule_action_choices()} + self.assertEqual(choices.get('test.dummy_action'), 'Dummy Action') + + def test_register_event_rule_action_as_decorator(self): + @register_event_rule_action + class DummyAction(EventRuleAction): + slug = 'test.dummy_action' + label = 'Dummy Action' + + self.assertIsInstance(get_event_rule_action('test.dummy_action'), DummyAction) + + def test_duplicate_slug_raises(self): + class FirstAction(EventRuleAction): + slug = 'test.duplicate_action' + label = 'First' + + class SecondAction(EventRuleAction): + slug = 'test.duplicate_action' + label = 'Second' + + register_event_rule_action(FirstAction) + with self.assertRaises(ImproperlyConfigured): + register_event_rule_action(SecondAction) + + def test_slug_starting_with_digit_rejected(self): + """A slug starting with a digit would sanitize into a GraphQL-invalid enum member name.""" + class DigitSlugAction(EventRuleAction): + slug = '2fa.notify' + label = 'Digit Slug Action' + + with self.assertRaises(ImproperlyConfigured): + register_event_rule_action(DigitSlugAction) + self.assertIsNone(get_event_rule_action('2fa.notify')) + + def test_slug_with_hyphen_rejected(self): + """Hyphens are not permitted, though plugin distribution names conventionally use them.""" + class HyphenSlugAction(EventRuleAction): + slug = 'my-plugin.open_ticket' + label = 'Hyphen Slug Action' + + with self.assertRaises(ImproperlyConfigured): + register_event_rule_action(HyphenSlugAction) + self.assertIsNone(get_event_rule_action('my-plugin.open_ticket')) + + def test_slug_with_leading_underscore_rejected(self): + """A leading underscore sanitizes into a "__"-prefixed name, which GraphQL reserves for introspection.""" + class LeadingUnderscoreAction(EventRuleAction): + slug = '_internal.foo' + label = 'Leading Underscore Action' + + with self.assertRaises(ImproperlyConfigured): + register_event_rule_action(LeadingUnderscoreAction) + self.assertIsNone(get_event_rule_action('_internal.foo')) + + def test_slug_with_uppercase_rejected(self): + """Slugs must be lowercase, though plugin/class names conventionally are not.""" + class UppercaseSlugAction(EventRuleAction): + slug = 'MyPlugin.action' + label = 'Uppercase Slug Action' + + with self.assertRaises(ImproperlyConfigured): + register_event_rule_action(UppercaseSlugAction) + self.assertIsNone(get_event_rule_action('MyPlugin.action')) + + def test_slug_enum_key_collision_rejected(self): + """Two distinct slugs that sanitize to the same GraphQL enum member name must not both register.""" + class DotAction(EventRuleAction): + slug = 'test.collision_action' + label = 'Dot Action' + + class UnderscoreAction(EventRuleAction): + slug = 'test_collision_action' + label = 'Underscore Action' + + register_event_rule_action(DotAction) + self.addCleanup(registry['event_rule_actions'].pop, 'test.collision_action', None) + with self.assertRaises(ImproperlyConfigured): + register_event_rule_action(UnderscoreAction) + self.assertIsNone(get_event_rule_action('test_collision_action')) + + def test_missing_slug_raises_at_registration(self): + # Class definition itself must succeed; only registration checks slug/label. + class NoSlugAction(EventRuleAction): + label = 'No Slug' + + with self.assertRaises(ImproperlyConfigured): + register_event_rule_action(NoSlugAction) + + def test_missing_label_raises_at_registration(self): + class NoLabelAction(EventRuleAction): + slug = 'test.no_label' + + with self.assertRaises(ImproperlyConfigured): + register_event_rule_action(NoLabelAction) + + def test_intermediate_base_class_without_slug_or_label_is_definable(self): + """ + slug/label are checked at registration, not class definition, so several concrete actions + can share an intermediate base class which sets neither. + """ + class PluginActionBase(EventRuleAction): + object_required = False + + def enqueue(self, **kwargs): + pass + + class ConcreteAction(PluginActionBase): + slug = 'test.intermediate_base_concrete_action' + label = 'Concrete Action' + + register_event_rule_action(ConcreteAction) + self.addCleanup(registry['event_rule_actions'].pop, 'test.intermediate_base_concrete_action', None) + + self.assertIsInstance(get_event_rule_action('test.intermediate_base_concrete_action'), ConcreteAction) + + def test_unregistered_slug_returns_none(self): + self.assertIsNone(get_event_rule_action('this.does.not.exist')) + + def test_core_actions_are_registered(self): + """WebhookAction/ScriptAction/NotificationAction are registered at app startup.""" + core_slugs = ( + EventRuleActionChoices.WEBHOOK, EventRuleActionChoices.SCRIPT, EventRuleActionChoices.NOTIFICATION, + ) + for slug in core_slugs: + self.assertIsNotNone(get_event_rule_action(slug)) + + def test_get_object_queryset_returns_none_without_object_model(self): + action = EventRuleAction() + self.assertIsNone(action.get_object_queryset()) + + def test_internal_validate_requires_object_when_object_required(self): + action = EventRuleAction() + action.object_required = True + with self.assertRaises(ValidationError): + action._validate(action_object=None, action_data={}) + + def test_internal_validate_passes_when_object_not_required(self): + action = EventRuleAction() + action.object_required = False + # Must not raise + action._validate(action_object=None, action_data={}) + + def test_internal_validate_rejects_wrong_object_type(self): + action = EventRuleAction() + action.object_model = Webhook + action.object_required = True + site = Site(name='Not A Webhook') + with self.assertRaises(ValidationError): + action._validate(action_object=site, action_data={}) + + def test_internal_validate_rejects_object_for_action_without_object_model(self): + """An action which declares no object_model must reject a target object outright.""" + action = EventRuleAction() + with self.assertRaises(ValidationError): + action._validate(action_object=Webhook(), action_data={}) + + def test_object_required_without_object_model_rejected_at_registration(self): + """object_required with no object_model could never be satisfied, so it's caught early.""" + class ImpossibleAction(EventRuleAction): + slug = 'test.impossible_action' + label = 'Impossible Action' + object_required = True + + with self.assertRaises(ImproperlyConfigured): + register_event_rule_action(ImpossibleAction) + self.assertIsNone(get_event_rule_action('test.impossible_action')) + + def test_get_object_label_defaults_to_object_model_verbose_name(self): + """The object picker's label defaults to the model's verbose name, capitalized.""" + self.assertEqual(get_event_rule_action(EventRuleActionChoices.NOTIFICATION).get_object_label(), + 'Notification group') + self.assertEqual(get_event_rule_action(EventRuleActionChoices.WEBHOOK).get_object_label(), 'Webhook') + + def test_get_object_label_honors_explicit_override(self): + class LabeledAction(EventRuleAction): + object_model = Webhook + object_label = 'Destination' + + self.assertEqual(LabeledAction().get_object_label(), 'Destination') + + def test_get_object_label_is_none_without_object_model(self): + self.assertIsNone(EventRuleAction().get_object_label()) + + def test_validate_is_noop_by_default(self): + action = EventRuleAction() + # Must not raise + action.validate(action_object=None, action_data={}) + + def test_validate_override_does_not_need_super(self): + """A subclass overriding validate() gets the base object_required check for free, no super() needed.""" + class CustomValidatingAction(EventRuleAction): + slug = 'test.custom_validating_action' + label = 'Custom Validating Action' + object_model = Webhook + object_required = True + + def validate(self, *, action_object, action_data): + if action_data.get('bad'): + raise ValidationError({'action_data': 'bad action_data for test'}) + + action = CustomValidatingAction() + + # The subclass's own check fires + with self.assertRaises(ValidationError): + action._validate(action_object=Webhook(), action_data={'bad': True}) + + # ...as does the base object_required check + with self.assertRaises(ValidationError): + action._validate(action_object=None, action_data={}) + + action._validate(action_object=Webhook(), action_data={}) # must not raise + + def test_enqueue_not_implemented_by_default(self): + action = EventRuleAction() + with self.assertRaises(NotImplementedError): + action.enqueue(event_rule=None, event_context={}, action_object=None, action_data={}) + + def test_is_plugin_provided_defaults_true_before_registration(self): + """is_plugin_provided is True on an instance that never goes through registration.""" + self.assertTrue(EventRuleAction().is_plugin_provided) + + +class EventRuleActionAvailabilityTestCase(TestCase): + """ + An EventRule with an unregistered action_type must remain loadable, skip gracefully during + processing, and display as "unavailable" -- but reject full_clean() until action_type changes. + """ + + @classmethod + def setUpTestData(cls): + site_type = ObjectType.objects.get_for_model(Site) + webhook = Webhook.objects.create(name='Availability Test Webhook', payload_url='http://localhost:9000/') + webhook_type = ObjectType.objects.get_for_model(Webhook) + + cls.healthy_rule = EventRule.objects.create( + name='Healthy Rule', + event_types=[OBJECT_CREATED], + action_type=EventRuleActionChoices.WEBHOOK, + action_object_type=webhook_type, + action_object_id=webhook.pk, + ) + cls.healthy_rule.object_types.set([site_type]) + + # .objects.create() calls save(), not full_clean(), so an unregistered action_type can be + # persisted directly, matching the state of a row whose providing plugin was uninstalled. + cls.unavailable_rule = EventRule.objects.create( + name='Unavailable Rule', + event_types=[OBJECT_CREATED], + action_type='someplugin.not_installed', + ) + cls.unavailable_rule.object_types.set([site_type]) + + def test_action_is_available_true_for_registered_action(self): + self.assertTrue(self.healthy_rule.action_is_available) + self.assertIsNotNone(self.healthy_rule.action_provider) + + def test_action_is_available_false_for_unregistered_action(self): + self.assertFalse(self.unavailable_rule.action_is_available) + self.assertIsNone(self.unavailable_rule.action_provider) + + def test_get_action_type_display_for_registered_action(self): + self.assertEqual(self.healthy_rule.get_action_type_display(), 'Webhook') + + def test_get_action_type_display_for_unregistered_action(self): + self.assertEqual( + self.unavailable_rule.get_action_type_display(), + 'someplugin.not_installed (unavailable)', + ) + + def test_get_action_type_color_for_registered_action(self): + self.assertIsNone(self.healthy_rule.get_action_type_color()) + + def test_get_action_type_color_for_unregistered_action(self): + self.assertEqual(self.unavailable_rule.get_action_type_color(), 'red') + + def test_clean_rejects_unchanged_unavailable_action_type(self): + """A persisted-but-unavailable action_type is rejected by full_clean() even when left unchanged.""" + rule = EventRule.objects.get(pk=self.unavailable_rule.pk) + rule.enabled = False + with self.assertRaises(ValidationError): + rule.full_clean() + + def test_clean_rejects_new_row_with_unregistered_action_type(self): + rule = EventRule( + name='New Unregistered Rule', + event_types=[OBJECT_CREATED], + action_type='someplugin.also_not_installed', + ) + with self.assertRaises(ValidationError): + rule.full_clean() + + def test_clean_rejects_changing_to_unregistered_action_type(self): + rule = EventRule.objects.get(pk=self.healthy_rule.pk) + rule.action_type = 'someplugin.newly_unregistered' + with self.assertRaises(ValidationError): + rule.full_clean() + + def test_clean_accepts_registered_action_with_valid_object(self): + rule = EventRule.objects.get(pk=self.healthy_rule.pk) + rule.full_clean() # must not raise + + +class EventRuleNoObjectActionTestCase(TestCase): + """ + Model-layer tests for an EventRuleAction which declares object_model=None (no target object). + """ + + def tearDown(self): + super().tearDown() + registry['event_rule_actions'].pop('test.model_no_object_action', None) + + def test_full_clean_and_save_with_no_object_action(self): + class NoObjectAction(EventRuleAction): + slug = 'test.model_no_object_action' + label = 'Model No-Object Action' + object_required = False + + register_event_rule_action(NoObjectAction) + + site_type = ObjectType.objects.get_for_model(Site) + rule = EventRule( + name='Model No-Object Rule', + event_types=[OBJECT_CREATED], + action_type='test.model_no_object_action', + ) + rule.full_clean() # must not raise: no action_object required or supplied + rule.save() + rule.object_types.set([site_type]) + + rule.refresh_from_db() + self.assertIsNone(rule.action_object_type) + self.assertIsNone(rule.action_object_id) + self.assertIsNone(rule.action_object) + class WebhookRenderHeadersTest(TestCase): diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index a511f3a6c..03e913896 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -15,11 +15,11 @@ from extras.filtersets import * from extras.models import * from tenancy.models import Tenant, TenantGroup from users.models import Group, User -from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, create_tags +from utilities.testing import BaseFilterSetTestMixin, ChangeLoggedFilterSetTestMixin, create_tags from virtualization.models import Cluster, ClusterGroup, ClusterType -class CustomFieldTestCase(TestCase, ChangeLoggedFilterSetTests): +class CustomFieldTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = CustomField.objects.all() filterset = CustomFieldFilterSet ignore_fields = ('default', 'related_object_filter', 'validation_schema') @@ -51,7 +51,8 @@ class CustomFieldTestCase(TestCase, ChangeLoggedFilterSetTests): filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT, ui_visible=CustomFieldUIVisibleChoices.IF_SET, ui_editable=CustomFieldUIEditableChoices.NO, - description='foobar2' + description='foobar2', + nulls_first=False ), CustomField( name='Custom Field 3', @@ -61,7 +62,8 @@ class CustomFieldTestCase(TestCase, ChangeLoggedFilterSetTests): filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED, ui_visible=CustomFieldUIVisibleChoices.HIDDEN, ui_editable=CustomFieldUIEditableChoices.HIDDEN, - description='foobar3' + description='foobar3', + nulls_first=False ), CustomField( name='Custom Field 4', @@ -141,6 +143,12 @@ class CustomFieldTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'ui_editable': CustomFieldUIEditableChoices.YES} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_status(self): + params = {'status': CustomFieldStatusChoices.STATUS_ACTIVE} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'status': CustomFieldStatusChoices.STATUS_DELETING} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) + def test_choice_set(self): params = {'choice_set': ['Choice Set 1', 'Choice Set 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -151,8 +159,14 @@ class CustomFieldTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_nulls_first(self): + params = {'nulls_first': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'nulls_first': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class CustomFieldChoiceSetTestCase(TestCase, ChangeLoggedFilterSetTests): + +class CustomFieldChoiceSetTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = CustomFieldChoiceSet.objects.all() filterset = CustomFieldChoiceSetFilterSet ignore_fields = ('extra_choices',) @@ -206,7 +220,7 @@ class CustomFieldChoiceSetTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class WebhookTestCase(TestCase, BaseFilterSetTests): +class WebhookTestCase(TestCase, BaseFilterSetTestMixin): queryset = Webhook.objects.all() filterset = WebhookFilterSet ignore_fields = ('additional_headers', 'body_template') @@ -219,6 +233,7 @@ class WebhookTestCase(TestCase, BaseFilterSetTests): payload_url='http://example.com/?1', http_method='GET', ssl_verification=True, + timeout=10, description='foobar1' ), Webhook( @@ -226,6 +241,7 @@ class WebhookTestCase(TestCase, BaseFilterSetTests): payload_url='http://example.com/?2', http_method='POST', ssl_verification=True, + timeout=20, description='foobar2' ), Webhook( @@ -233,6 +249,7 @@ class WebhookTestCase(TestCase, BaseFilterSetTests): payload_url='http://example.com/?3', http_method='PATCH', ssl_verification=False, + timeout=30, description='foobar3' ), Webhook( @@ -270,8 +287,19 @@ class WebhookTestCase(TestCase, BaseFilterSetTests): params = {'ssl_verification': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_timeout(self): + params = {'timeout': [10, 20]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class EventRuleTestCase(TestCase, BaseFilterSetTests): + def test_timeout_range(self): + # Backs the minimum/maximum timeout fields exposed by WebhookFilterForm + params = {'timeout__gte': [20]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'timeout__lte': [20]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class EventRuleTestCase(TestCase, BaseFilterSetTestMixin): queryset = EventRule.objects.all() filterset = EventRuleFilterSet ignore_fields = ('action_data', 'conditions', 'event_types') @@ -381,6 +409,49 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests): params = {'action_type': [EventRuleActionChoices.SCRIPT]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_action_is_available(self): + unavailable_rule = EventRule.objects.create( + name='Unavailable Filterset Rule', + event_types=[OBJECT_CREATED], + action_type='someplugin.not_installed_filterset_test', + ) + unavailable_rule.object_types.set([ObjectType.objects.get_for_model(Site)]) + + params = {'action_is_available': True} + qs = self.filterset(params, EventRule.objects.all()).qs + self.assertEqual(qs.count(), 5) + self.assertNotIn(unavailable_rule, qs) + + params = {'action_is_available': False} + qs = self.filterset(params, EventRule.objects.all()).qs + self.assertEqual(qs.count(), 1) + self.assertEqual(qs.first(), unavailable_rule) + + def test_action_type_registered_plugin_style_slug(self): + """A plugin-registered action slug is a valid action_type filter value, not just the core actions.""" + from netbox.event_rules import EventRuleAction, register_event_rule_action + from netbox.registry import registry + + class FilterTestAction(EventRuleAction): + slug = 'test.filterset_registered_action' + label = 'Filterset Test Action' + object_required = False + + register_event_rule_action(FilterTestAction) + self.addCleanup(registry['event_rule_actions'].pop, FilterTestAction.slug, None) + + rule = EventRule.objects.create( + name='Filterset Registered Action Rule', + event_types=[OBJECT_CREATED], + action_type=FilterTestAction.slug, + ) + rule.object_types.set([ObjectType.objects.get_for_model(Site)]) + + params = {'action_type': [FilterTestAction.slug]} + qs = self.filterset(params, EventRule.objects.all()).qs + self.assertEqual(qs.count(), 1) + self.assertEqual(qs.first(), rule) + def test_enabled(self): params = {'enabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -392,7 +463,7 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class CustomLinkTestCase(TestCase, ChangeLoggedFilterSetTests): +class CustomLinkTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = CustomLink.objects.all() filterset = CustomLinkFilterSet @@ -461,7 +532,7 @@ class CustomLinkTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class SavedFilterTestCase(TestCase, ChangeLoggedFilterSetTests): +class SavedFilterTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = SavedFilter.objects.all() filterset = SavedFilterFilterSet ignore_fields = ('parameters',) @@ -566,7 +637,7 @@ class SavedFilterTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class BookmarkTestCase(TestCase, BaseFilterSetTests): +class BookmarkTestCase(TestCase, BaseFilterSetTestMixin): queryset = Bookmark.objects.all() filterset = BookmarkFilterSet @@ -635,7 +706,7 @@ class BookmarkTestCase(TestCase, BaseFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) -class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): +class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = ExportTemplate.objects.all() filterset = ExportTemplateFilterSet ignore_fields = ('template_code', 'environment_params', 'data_path') @@ -711,7 +782,7 @@ class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class ImageAttachmentTestCase(TestCase, ChangeLoggedFilterSetTests): +class ImageAttachmentTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = ImageAttachment.objects.all() filterset = ImageAttachmentFilterSet ignore_fields = ('image',) @@ -805,7 +876,7 @@ class ImageAttachmentTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class TableConfigTestCase(TestCase, ChangeLoggedFilterSetTests): +class TableConfigTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = TableConfig.objects.all() filterset = TableConfigFilterSet ignore_fields = ('columns', 'ordering') @@ -861,7 +932,7 @@ class TableConfigTestCase(TestCase, ChangeLoggedFilterSetTests): ) -class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests): +class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = JournalEntry.objects.all() filterset = JournalEntryFilterSet @@ -964,7 +1035,7 @@ class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class ConfigContextProfileTestCase(TestCase, ChangeLoggedFilterSetTests): +class ConfigContextProfileTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = ConfigContextProfile.objects.all() filterset = ConfigContextProfileFilterSet ignore_fields = ('schema', 'data_path') @@ -997,7 +1068,7 @@ class ConfigContextProfileTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): +class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = ConfigContext.objects.all() filterset = ConfigContextFilterSet ignore_fields = ('data', 'data_path') @@ -1240,7 +1311,7 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class ConfigTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): +class ConfigTemplateTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = ConfigTemplate.objects.all() filterset = ConfigTemplateFilterSet ignore_fields = ('template_code', 'environment_params', 'data_path') @@ -1306,7 +1377,7 @@ class ConfigTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class TagTestCase(TestCase, ChangeLoggedFilterSetTests): +class TagTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = Tag.objects.all() filterset = TagFilterSet ignore_fields = ( @@ -1629,7 +1700,7 @@ class ChangeLoggedFilterSetTestCase(TestCase): self.assertEqual(self.queryset.count(), 4) -class NotificationGroupTestCase(TestCase, BaseFilterSetTests): +class NotificationGroupTestCase(TestCase, BaseFilterSetTestMixin): queryset = NotificationGroup.objects.all() filterset = NotificationGroupFilterSet diff --git a/netbox/extras/tests/test_forms.py b/netbox/extras/tests/test_forms.py index 909d72801..60744c5bb 100644 --- a/netbox/extras/tests/test_forms.py +++ b/netbox/extras/tests/test_forms.py @@ -1,19 +1,25 @@ import tempfile from pathlib import Path -from django.core.exceptions import NON_FIELD_ERRORS +from django.core.exceptions import NON_FIELD_ERRORS, ValidationError from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase from core.choices import ManagedFileRootPathChoices +from core.events import OBJECT_CREATED from core.models import DataSource, ObjectType from dcim.forms import SiteForm from dcim.models import Site -from extras.choices import CustomFieldTypeChoices +from extras.choices import CustomFieldTypeChoices, EventRuleActionChoices from extras.forms import SavedFilterForm, TableConfigBulkEditForm, TableConfigForm -from extras.forms.model_forms import CustomFieldChoiceSetForm +from extras.forms.bulk_import import EventRuleImportForm +from extras.forms.filtersets import EventRuleFilterForm +from extras.forms.model_forms import CustomFieldChoiceSetForm, EventRuleForm from extras.forms.scripts import ScriptFileForm -from extras.models import CustomField, CustomFieldChoiceSet, ScriptModule +from extras.models import CustomField, CustomFieldChoiceSet, EventRule, NotificationGroup, Script, ScriptModule, Webhook +from netbox.event_rules import EventRuleAction, register_event_rule_action +from netbox.registry import registry +from utilities.forms.widgets import HTMXSelect class CustomFieldModelFormTestCase(TestCase): @@ -337,3 +343,353 @@ class TableConfigFormTestCase(TestCase): form = TableConfigBulkEditForm() self.assertIn('changelog_message', form.fields) self.assertIn('changelog_message', form.meta_fields) + + +class EventRuleFormTestCase(TestCase): + """ + EventRuleForm's action_choice field is built dynamically from the EventRuleAction registry, + for both core actions and those registered by a plugin. + """ + + def tearDown(self): + super().tearDown() + registry['event_rule_actions'].pop('test.form_no_object_action', None) + + def test_action_type_widget_is_htmx_select(self): + """ + action_choice refreshes via HTMX when action_type changes. The widget must be set on the + field itself: Meta.widgets applies only to fields the ModelForm generates from the model, + and action_type is declared explicitly. + """ + form = EventRuleForm() + widget = form.fields['action_type'].widget + self.assertIsInstance(widget, HTMXSelect) + self.assertEqual(widget.attrs.get('hx-target'), '#event-rule-action') + + def test_action_choice_field_for_webhook(self): + webhook = Webhook.objects.create(name='Form Test Webhook', payload_url='http://localhost:9000/') + form = EventRuleForm(data={'action_type': EventRuleActionChoices.WEBHOOK}) + self.assertIn('action_choice', form.fields) + self.assertIn(webhook, form.fields['action_choice'].queryset) + + def test_action_choice_field_for_script(self): + form = EventRuleForm(data={'action_type': EventRuleActionChoices.SCRIPT}) + self.assertIn('action_choice', form.fields) + self.assertEqual(form.fields['action_choice'].queryset.model, Script) + + def test_action_choice_field_for_notification(self): + form = EventRuleForm(data={'action_type': EventRuleActionChoices.NOTIFICATION}) + self.assertIn('action_choice', form.fields) + self.assertEqual(form.fields['action_choice'].queryset.model, NotificationGroup) + + def test_action_choice_field_labels(self): + """The object picker is labeled for the object being selected, not for the action itself.""" + for action_type, label in ( + (EventRuleActionChoices.WEBHOOK, 'Webhook'), + (EventRuleActionChoices.SCRIPT, 'Script'), + (EventRuleActionChoices.NOTIFICATION, 'Notification group'), + ): + form = EventRuleForm(data={'action_type': action_type}) + self.assertEqual(form.fields['action_choice'].label, label) + + def test_action_choice_field_honors_object_label(self): + class LabeledObjectAction(EventRuleAction): + slug = 'test.form_labeled_object_action' + label = 'Form Labeled Object Action' + object_model = Webhook + object_label = 'Destination' + + register_event_rule_action(LabeledObjectAction) + self.addCleanup(registry['event_rule_actions'].pop, LabeledObjectAction.slug, None) + + form = EventRuleForm(data={'action_type': LabeledObjectAction.slug}) + self.assertEqual(form.fields['action_choice'].label, 'Destination') + + def test_action_choice_field_omitted_for_registered_no_object_action(self): + class NoObjectAction(EventRuleAction): + slug = 'test.form_no_object_action' + label = 'Form No-Object Action' + object_required = False + + register_event_rule_action(NoObjectAction) + + form = EventRuleForm(data={'action_type': 'test.form_no_object_action'}) + self.assertNotIn('action_choice', form.fields) + + def test_action_choice_field_falls_back_to_initial_for_unregistered_action(self): + """ + get_field_value() falls back to the field's own initial (webhook) for an unregistered + action_type, so init_action_choice() still builds a usable picker. + """ + form = EventRuleForm(data={'action_type': 'not.a.registered.action'}) + self.assertIn('action_choice', form.fields) + self.assertEqual(form.fields['action_choice'].queryset.model, Webhook) + + def test_submit_and_save_with_registered_no_object_action(self): + """A runtime-registered action can be submitted and saved end-to-end through the form.""" + class NoObjectAction(EventRuleAction): + slug = 'test.form_no_object_action' + label = 'Form No-Object Action' + object_required = False + + def enqueue(self, **kwargs): + pass + + register_event_rule_action(NoObjectAction) + + object_type = ObjectType.objects.get_for_model(Site) + form = EventRuleForm(data={ + 'name': 'Form No-Object Rule', + 'object_types': [object_type.pk], + 'event_types': [OBJECT_CREATED], + 'action_type': 'test.form_no_object_action', + }) + self.assertTrue(form.is_valid(), form.errors) + rule = form.save() + self.assertIsNone(rule.action_object_type) + self.assertIsNone(rule.action_object_id) + + def test_submit_and_save_webhook_action(self): + """The generalized form still saves a core Webhook action correctly.""" + webhook = Webhook.objects.create(name='Form Submit Webhook', payload_url='http://localhost:9000/') + object_type = ObjectType.objects.get_for_model(Site) + form = EventRuleForm(data={ + 'name': 'Form Webhook Rule', + 'object_types': [object_type.pk], + 'event_types': [OBJECT_CREATED], + 'action_type': EventRuleActionChoices.WEBHOOK, + 'action_choice': webhook.pk, + }) + self.assertTrue(form.is_valid(), form.errors) + rule = form.save() + self.assertEqual(rule.action_object, webhook) + + def test_switching_to_optional_object_action_clears_stale_action_object(self): + """ + Switching an existing rule to an action which declares object_model but not + object_required, leaving the picker blank, must clear the old action_object. + """ + class OptionalObjectAction(EventRuleAction): + slug = 'test.optional_object_action' + label = 'Optional Object Action' + object_model = Webhook + object_required = False + + register_event_rule_action(OptionalObjectAction) + self.addCleanup(registry['event_rule_actions'].pop, 'test.optional_object_action', None) + + webhook = Webhook.objects.create(name='Stale Webhook', payload_url='http://localhost:9000/') + webhook_type = ObjectType.objects.get_for_model(Webhook) + object_type = ObjectType.objects.get_for_model(Site) + rule = EventRule.objects.create( + name='Stale Object Rule', + event_types=[OBJECT_CREATED], + action_type=EventRuleActionChoices.WEBHOOK, + action_object_type=webhook_type, + action_object_id=webhook.pk, + ) + rule.object_types.set([object_type]) + + form = EventRuleForm(data={ + 'name': rule.name, + 'object_types': [object_type.pk], + 'event_types': [OBJECT_CREATED], + 'action_type': 'test.optional_object_action', + # action_choice omitted: the user left the picker blank + }, instance=rule) + self.assertTrue(form.is_valid(), form.errors) + saved = form.save() + self.assertIsNone(saved.action_object_type) + self.assertIsNone(saved.action_object_id) + + +class EventRuleFilterFormTestCase(TestCase): + + def test_action_type_choices_reflect_the_live_registry(self): + """ + The filter form's action_type choices must be read from the registry on access, not frozen + when this module was first imported. + """ + class FilterFormAction(EventRuleAction): + slug = 'test.filter_form_action' + label = 'Filter Form Action' + + register_event_rule_action(FilterFormAction) + self.addCleanup(registry['event_rule_actions'].pop, FilterFormAction.slug, None) + + choices = dict(EventRuleFilterForm().fields['action_type'].choices) + self.assertEqual(choices.get(FilterFormAction.slug), 'Filter Form Action') + self.assertIn(None, choices) # The blank choice is retained + + +class EventRuleImportFormTestCase(TestCase): + """ + EventRuleImportForm resolves action_object via each registered action's resolve_import_object() + hook, and treats it as optional (an action need not operate against a target object). + """ + + def tearDown(self): + super().tearDown() + registry['event_rule_actions'].pop('test.import_no_object_action', None) + + def test_resolves_webhook_by_name(self): + webhook = Webhook.objects.create(name='Import Test Webhook', payload_url='http://localhost:9000/') + form = EventRuleImportForm(data={ + 'name': 'Import Webhook Rule', + 'object_types': 'dcim.site', + 'event_types': 'object_created', + 'action_type': EventRuleActionChoices.WEBHOOK, + 'action_object': webhook.name, + }) + self.assertTrue(form.is_valid(), form.errors) + self.assertEqual(form.instance.action_object, webhook) + + def test_resolves_notification_group_by_name(self): + """The import form resolves a notification group, not just webhooks and scripts.""" + group = NotificationGroup.objects.create(name='Import Test Group') + form = EventRuleImportForm(data={ + 'name': 'Import Notification Rule', + 'object_types': 'dcim.site', + 'event_types': 'object_created', + 'action_type': EventRuleActionChoices.NOTIFICATION, + 'action_object': group.name, + }) + self.assertTrue(form.is_valid(), form.errors) + self.assertEqual(form.instance.action_object, group) + + def test_unresolvable_webhook_name_is_rejected(self): + form = EventRuleImportForm(data={ + 'name': 'Import Bad Webhook Rule', + 'object_types': 'dcim.site', + 'event_types': 'object_created', + 'action_type': EventRuleActionChoices.WEBHOOK, + 'action_object': 'Does Not Exist', + }) + self.assertFalse(form.is_valid()) + self.assertIn('action_object', form.errors) + + def test_unregistered_action_type_is_rejected(self): + form = EventRuleImportForm(data={ + 'name': 'Import Bad Type Rule', + 'object_types': 'dcim.site', + 'event_types': 'object_created', + 'action_type': 'not.a.registered.action', + 'action_object': 'whatever', + }) + self.assertFalse(form.is_valid()) + self.assertIn('action_type', form.errors) + + def test_submit_no_object_action_with_blank_action_object_succeeds(self): + """A blank action_object must be accepted for bulk-importing a no-object action.""" + class NoObjectAction(EventRuleAction): + slug = 'test.import_no_object_action' + label = 'Import No-Object Action' + object_required = False + + register_event_rule_action(NoObjectAction) + + form = EventRuleImportForm(data={ + 'name': 'Import No-Object Rule', + 'object_types': 'dcim.site', + 'event_types': 'object_created', + 'action_type': 'test.import_no_object_action', + 'action_object': '', + }) + self.assertTrue(form.is_valid(), form.errors) + rule = form.save() + self.assertIsNone(rule.action_object_type) + self.assertIsNone(rule.action_object_id) + + def test_blank_action_object_rejected_for_object_required_action(self): + """A blank action_object must be rejected cleanly (not raise) for an action which requires one.""" + form = EventRuleImportForm(data={ + 'name': 'Import Webhook No Object', + 'object_types': 'dcim.site', + 'event_types': 'object_created', + 'action_type': EventRuleActionChoices.WEBHOOK, + 'action_object': '', + }) + self.assertFalse(form.is_valid()) + self.assertIn('action_object', form.errors) + + def test_action_object_rejected_for_action_without_object_model(self): + """ + An action declaring no object_model rejects a supplied action_object as inapplicable, + rather than reporting it as an unsupported bulk import. + """ + class NoObjectAction(EventRuleAction): + slug = 'test.import_no_object_action' + label = 'Import No-Object Action' + object_required = False + + register_event_rule_action(NoObjectAction) + + form = EventRuleImportForm(data={ + 'name': 'Import No-Object Rule With Object', + 'object_types': 'dcim.site', + 'event_types': 'object_created', + 'action_type': 'test.import_no_object_action', + 'action_object': 'Some Object', + }) + self.assertFalse(form.is_valid()) + self.assertIn('does not operate against a target object', str(form.errors['action_object'])) + + def test_csv_update_to_optional_object_action_clears_stale_action_object(self): + """ + A CSV row updating an existing rule to an action_type which declares object_model but not + object_required, with action_object left blank, must clear the previous action_object. + """ + class OptionalObjectAction(EventRuleAction): + slug = 'test.import_optional_object_action' + label = 'Import Optional Object Action' + object_model = Webhook + object_required = False + + register_event_rule_action(OptionalObjectAction) + self.addCleanup(registry['event_rule_actions'].pop, 'test.import_optional_object_action', None) + + webhook = Webhook.objects.create(name='CSV Stale Webhook', payload_url='http://localhost:9000/') + webhook_type = ObjectType.objects.get_for_model(Webhook) + rule = EventRule.objects.create( + name='CSV Stale Object Rule', + event_types=[OBJECT_CREATED], + action_type=EventRuleActionChoices.WEBHOOK, + action_object_type=webhook_type, + action_object_id=webhook.pk, + ) + + form = EventRuleImportForm(data={ + 'name': rule.name, + 'object_types': 'dcim.site', + 'event_types': 'object_created', + 'action_type': 'test.import_optional_object_action', + 'action_object': '', + }, instance=rule) + self.assertTrue(form.is_valid(), form.errors) + saved = form.save() + self.assertIsNone(saved.action_object_type) + self.assertIsNone(saved.action_object_id) + + def test_action_validate_error_on_unexposed_field_becomes_non_field_error(self): + """A validate() error keyed by a field this form doesn't expose (e.g. action_data) must not raise.""" + class ActionDataValidatingAction(EventRuleAction): + slug = 'test.import_action_data_validating' + label = 'Import Action Data Validating' + object_required = False + + def validate(self, *, action_object, action_data): + raise ValidationError({'action_data': 'Bad action_data for test'}) + + register_event_rule_action(ActionDataValidatingAction) + self.addCleanup(registry['event_rule_actions'].pop, 'test.import_action_data_validating', None) + + form = EventRuleImportForm(data={ + 'name': 'Import Bad Action Data Rule', + 'object_types': 'dcim.site', + 'event_types': 'object_created', + 'action_type': 'test.import_action_data_validating', + 'action_object': '', + }) + self.assertFalse(form.is_valid()) + self.assertIn(NON_FIELD_ERRORS, form.errors) + self.assertIn('Bad action_data for test', form.errors[NON_FIELD_ERRORS]) diff --git a/netbox/extras/tests/test_graphql.py b/netbox/extras/tests/test_graphql.py new file mode 100644 index 000000000..d180e69b5 --- /dev/null +++ b/netbox/extras/tests/test_graphql.py @@ -0,0 +1,84 @@ +import json + +from django.urls import reverse +from rest_framework import status + +from core.events import OBJECT_CREATED +from core.models import ObjectType +from dcim.models import Site +from extras.choices import CustomFieldStatusChoices, CustomFieldTypeChoices, EventRuleActionChoices +from extras.graphql.enums import EventRuleActionEnum +from extras.models import CustomField, EventRule, Webhook +from utilities.testing import APITestCase + + +class EventRuleActionEnumTestCase(APITestCase): + """EventRuleActionEnum must reflect the live action registry, and the filter must use it.""" + + def test_enum_contains_core_actions(self): + # A subset check, since an installed plugin may register actions of its own + values = {member.value for member in EventRuleActionEnum} + core_slugs = { + EventRuleActionChoices.WEBHOOK, EventRuleActionChoices.SCRIPT, EventRuleActionChoices.NOTIFICATION, + } + self.assertLessEqual(core_slugs, values) + + def test_filter_event_rules_by_action_type(self): + webhook = Webhook.objects.create(name='GraphQL Enum Test Webhook', payload_url='http://localhost:9000/') + webhook_type = ObjectType.objects.get_for_model(Webhook) + site_type = ObjectType.objects.get_for_model(Site) + + webhook_rule = EventRule.objects.create( + name='GraphQL Enum Webhook Rule', + event_types=[OBJECT_CREATED], + action_type=EventRuleActionChoices.WEBHOOK, + action_object_type=webhook_type, + action_object_id=webhook.pk, + ) + webhook_rule.object_types.set([site_type]) + + script_rule = EventRule.objects.create( + name='GraphQL Enum Script Rule', + event_types=[OBJECT_CREATED], + action_type=EventRuleActionChoices.SCRIPT, + ) + script_rule.object_types.set([site_type]) + + self.add_permissions('extras.view_eventrule') + url = reverse('graphql') + query = '{event_rule_list(filters: {action_type: {exact: WEBHOOK}}) {name action_type}}' + response = self.client.post(url, data={'query': query}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + + data = json.loads(response.content) + self.assertNotIn('errors', data) + names = {rule['name'] for rule in data['data']['event_rule_list']} + self.assertEqual(names, {'GraphQL Enum Webhook Rule'}) + + +class CustomFieldStatusFilterTestCase(APITestCase): + """A field which is not live is invisible everywhere else, so its status must be queryable.""" + + def test_filter_custom_fields_by_status(self): + site_type = ObjectType.objects.get_for_model(Site) + for name, status_ in ( + ('graphql_active_field', CustomFieldStatusChoices.STATUS_ACTIVE), + ('graphql_provisioning_field', CustomFieldStatusChoices.STATUS_PROVISIONING), + ): + custom_field = CustomField.objects.create(type=CustomFieldTypeChoices.TYPE_TEXT, name=name) + custom_field.object_types.set([site_type]) + # Applied via the queryset, as CustomField.status is not directly writable + CustomField.objects.filter(pk=custom_field.pk).update(status=status_) + + self.add_permissions('extras.view_customfield') + url = reverse('graphql') + query = '{custom_field_list(filters: {status: {exact: STATUS_PROVISIONING}}) {name status}}' + response = self.client.post(url, data={'query': query}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + + data = json.loads(response.content) + self.assertNotIn('errors', data) + self.assertEqual( + [(cf['name'], cf['status']) for cf in data['data']['custom_field_list']], + [('graphql_provisioning_field', CustomFieldStatusChoices.STATUS_PROVISIONING)] + ) diff --git a/netbox/extras/tests/test_management_commands.py b/netbox/extras/tests/test_management_commands.py index 477639e08..ea1c3518f 100644 --- a/netbox/extras/tests/test_management_commands.py +++ b/netbox/extras/tests/test_management_commands.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch from django.contrib.contenttypes.models import ContentType from django.core.management import call_command from django.core.management.base import CommandError +from django.db.models import F from django.test import TestCase from core.choices import JobNotificationChoices @@ -12,11 +13,12 @@ from dcim.choices import InterfaceTypeChoices from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site from extras.management.commands import renaturalize, webhook_receiver from extras.management.commands.webhook_receiver import WebhookHandler -from extras.models import ImageAttachment +from extras.models import ConfigContext, ImageAttachment from extras.scripts import Script, StringVar from extras.tests.test_models import OverwriteStyleMemoryStorage, UnreadableSizeMemoryStorage from users.models import User from utilities.fields import NaturalOrderingField +from virtualization.models import VirtualMachine class ReindexTestCase(TestCase): @@ -438,6 +440,87 @@ class RunScriptTestCase(TestCase): call_command('runscript', 'test.Script', user='admin', stdout=StringIO()) +class RebuildConfigContextCacheCommandTest(TestCase): + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Mfr', slug='mfr') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='DT', slug='dt') + role = DeviceRole.objects.create(name='Role', slug='role') + site = Site.objects.create(name='Site', slug='site') + cls.device = Device.objects.create(name='Device', device_type=devicetype, role=role, site=site) + cls.vm = VirtualMachine.objects.create(name='VM', site=site, role=role) + ConfigContext.objects.create(name='CC', weight=100, data={'foo': 'bar'}) + + def test_command_populates_null_caches(self): + Device.objects.update(_config_context_data=None) + VirtualMachine.objects.update(_config_context_data=None) + + call_command('rebuild_config_context_cache') + + self.device.refresh_from_db() + self.vm.refresh_from_db() + self.assertEqual(self.device._config_context_data, {'foo': 'bar'}) + self.assertEqual(self.vm._config_context_data, {'foo': 'bar'}) + + def test_command_skips_populated_caches_by_default(self): + # Seed a stale value; without --force the command must leave already-populated caches alone. + Device.objects.update(_config_context_data={'stale': True}) + VirtualMachine.objects.update(_config_context_data={'stale': True}) + + call_command('rebuild_config_context_cache') + + self.device.refresh_from_db() + self.vm.refresh_from_db() + self.assertEqual(self.device._config_context_data, {'stale': True}) + self.assertEqual(self.vm._config_context_data, {'stale': True}) + + def test_command_force_rerenders_populated_caches(self): + # A stale cache must be re-rendered from scratch when --force is given. + Device.objects.update(_config_context_data={'stale': True}) + VirtualMachine.objects.update(_config_context_data={'stale': True}) + + call_command('rebuild_config_context_cache', '--force') + + self.device.refresh_from_db() + self.vm.refresh_from_db() + self.assertEqual(self.device._config_context_data, {'foo': 'bar'}) + self.assertEqual(self.vm._config_context_data, {'foo': 'bar'}) + + def test_command_reports_rendered_counts(self): + Device.objects.update(_config_context_data=None) + VirtualMachine.objects.update(_config_context_data=None) + + out = StringIO() + call_command('rebuild_config_context_cache', stdout=out) + output = out.getvalue() + + self.assertIn('Rendered 1 dcim.device object(s).', output) + self.assertIn('Rendered 1 virtualization.virtualmachine object(s).', output) + self.assertIn('Finished.', output) + + def test_command_respects_generation_guard(self): + """ + Running the command on a live system must not clobber a concurrent invalidation: if the + generation counter is bumped while an object is being rendered, the compare-and-set write + is rejected and the cache stays NULL for the background sweep to repopulate. + """ + Device.objects.update(_config_context_data=None) + + def racing_render(device_self): + # Simulate a concurrent invalidation committing mid-render. + Device.objects.filter(pk=device_self.pk).update( + _config_context_generation=F('_config_context_generation') + 1 + ) + return {'stale': True} + + with patch.object(Device, 'render_config_context', autospec=True, side_effect=racing_render): + call_command('rebuild_config_context_cache') + + self.device.refresh_from_db() + self.assertIsNone(self.device._config_context_data) + + class WebhookReceiverTestCase(TestCase): def test_starts_http_server(self): out = StringIO() diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index d6862a246..20d5126e8 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -13,10 +13,11 @@ from django.core.files.storage import Storage from django.core.files.uploadedfile import SimpleUploadedFile from django.db import connection from django.forms import ValidationError -from django.test import TestCase, tag +from django.test import TestCase, override_settings, tag from django.test.utils import CaptureQueriesContext from jinja2 import DebugUndefined, StrictUndefined, TemplateError, TemplateSyntaxError, UndefinedError from PIL import Image +from rq.queue import Queue from core.events import OBJECT_CREATED from core.models import AutoSyncRecord, DataSource, ObjectType @@ -1270,7 +1271,7 @@ class JinjaEnvFilterTestCase(TestCase): self.assertEqual(output, 'secret') def test_user_defined_filter_overrides_default(self): - with self.settings(JINJA2_FILTERS={'env': lambda name: 'overridden'}): + with self.settings(JINJA_FILTERS={'env': lambda name: 'overridden'}): output = render_jinja2("{{ 'NETBOX_TEST_TOKEN' | env }}", {}) self.assertEqual(output, 'overridden') @@ -1303,7 +1304,7 @@ class SanitizeHTTPHeaderFilterTestCase(TestCase): def test_render_filters_take_precedence_over_user_config(self): # A per-render filter cannot be shadowed by a user-configured filter of the same name - with self.settings(JINJA2_FILTERS={'header_safe': lambda v: 'shadowed'}): + with self.settings(JINJA_FILTERS={'header_safe': lambda v: 'shadowed'}): output = render_jinja2( "{{ value | header_safe }}", {'value': 'a\r\nb'}, @@ -1581,7 +1582,7 @@ class ExportTemplateRenderTestCase(TestCase): self.assertEqual(response.content.decode(), 'Site A\nSite B\nSite C\n') -class WebhookTestCase(TestCase): +class WebhookPayloadUrlValidationTestCase(TestCase): """Tests for Webhook.clean()'s validation of payload_url (#22828).""" def test_payload_url_accepts_literal_url(self): @@ -1676,8 +1677,16 @@ class EventRuleTestCase(TestCase): """ clean() should accept a JSON object (or null) as action_data. """ + webhook = Webhook.objects.create(name='Action Data Test Webhook', payload_url='http://localhost:9000/') + webhook_type = ObjectType.objects.get_for_model(Webhook) for value in ({'key': 'value'}, None): - rule = EventRule(name='test', event_types=[OBJECT_CREATED], action_data=value) + rule = EventRule( + name='test', + event_types=[OBJECT_CREATED], + action_data=value, + action_object_type=webhook_type, + action_object_id=webhook.pk, + ) rule.clean() def test_action_data_clean_rejects_non_dict(self): @@ -1887,3 +1896,70 @@ class JinjaEnvironmentParamsIntegrationTestCase(TestCase): # ConfigTemplate always forces autoescape off (#22652). template = self._make_template({}) self.assertEqual(template.get_environment_params(), {'autoescape': False}) + + +@override_settings(RQ_DEFAULT_TIMEOUT=300) +class WebhookTestCase(TestCase): + + def test_timeout_must_be_less_than_job_timeout(self): + """ + A timeout at or above RQ_DEFAULT_TIMEOUT leaves no room for the request's own timeout to apply, and + is rejected. + """ + webhook = Webhook(name='Webhook 1', payload_url='http://localhost:9000/') + + for timeout in (300, 301): + webhook.timeout = timeout + with self.assertRaises(ValidationError): + webhook.full_clean() + + def test_timeout_below_job_timeout_is_valid(self): + webhook = Webhook(name='Webhook 1', payload_url='http://localhost:9000/', timeout=299) + webhook.full_clean() + + def test_null_timeout_is_valid(self): + webhook = Webhook(name='Webhook 1', payload_url='http://localhost:9000/') + webhook.full_clean() + + @override_settings(RQ_DEFAULT_TIMEOUT='1h') + def test_job_timeout_duration_string_is_validated(self): + """ + RQ also accepts a string timeout such as "1h", which must be normalized before comparison rather + than bypassing the check. + """ + webhook = Webhook(name='Webhook 1', payload_url='http://localhost:9000/', timeout=3600) + with self.assertRaises(ValidationError): + webhook.full_clean() + + webhook.timeout = 3599 + webhook.full_clean() + + @override_settings(RQ_DEFAULT_TIMEOUT='60') + def test_job_timeout_numeric_string_is_validated(self): + webhook = Webhook(name='Webhook 1', payload_url='http://localhost:9000/', timeout=60) + with self.assertRaises(ValidationError): + webhook.full_clean() + + webhook.timeout = 59 + webhook.full_clean() + + @override_settings(RQ_DEFAULT_TIMEOUT=-1) + def test_unbounded_job_timeout_skips_validation(self): + """ + A negative RQ timeout (-1) disables RQ's death penalty, so there is no job timeout to validate against. + """ + webhook = Webhook(name='Webhook 1', payload_url='http://localhost:9000/', timeout=3600) + webhook.full_clean() + + @override_settings(RQ_DEFAULT_TIMEOUT=0) + def test_zero_job_timeout_is_validated_against_queue_default(self): + """ + A zero (or absent) RQ timeout is not unbounded: RQ falls back to the queue's own default, which the + webhook timeout must still stay below. + """ + webhook = Webhook(name='Webhook 1', payload_url='http://localhost:9000/', timeout=Queue.DEFAULT_TIMEOUT) + with self.assertRaises(ValidationError): + webhook.full_clean() + + webhook.timeout = Queue.DEFAULT_TIMEOUT - 1 + webhook.full_clean() diff --git a/netbox/extras/tests/test_tables.py b/netbox/extras/tests/test_tables.py index 1fbd1fe2b..aa0976627 100644 --- a/netbox/extras/tests/test_tables.py +++ b/netbox/extras/tests/test_tables.py @@ -1,4 +1,10 @@ -from extras.models import Bookmark, Notification, Subscription +from django.test import TestCase + +from core.events import OBJECT_CREATED +from core.models import ObjectType +from dcim.models import Site +from extras.choices import CustomFieldStatusChoices, CustomFieldTypeChoices +from extras.models import Bookmark, CustomField, EventRule, Notification, Subscription from extras.tables import * from utilities.testing import TableTestCases @@ -7,6 +13,66 @@ class CustomFieldTableTestCase(TableTestCases.StandardTableTestCase): table = CustomFieldTable +class CustomFieldStatusColumnTestCase(TestCase): + """ + A field which is not live must be distinguishable at a glance from one which is: deleting a + field with a large amount of stored data reports success while leaving it listed until the purge + job completes (see CustomFieldStatusColumn). + """ + @classmethod + def setUpTestData(cls): + for status in ( + CustomFieldStatusChoices.STATUS_ACTIVE, + CustomFieldStatusChoices.STATUS_PROVISIONING, + CustomFieldStatusChoices.STATUS_DELETING, + ): + custom_field = CustomField.objects.create( + name=f'field_{status}', type=CustomFieldTypeChoices.TYPE_TEXT + ) + # Applied via the queryset to bypass the guard against modifying a pending field + CustomField.objects.filter(pk=custom_field.pk).update(status=status) + + def _row(self, status): + table = CustomFieldTable(CustomField.objects.filter(status=status)) + return table.rows[0] + + def test_status_is_shown_by_default(self): + self.assertIn('status', CustomFieldTable.Meta.default_columns) + + def test_active_field_renders_a_green_checkmark(self): + cell = self._row(CustomFieldStatusChoices.STATUS_ACTIVE).get_cell('status') + + self.assertInHTML( + '', cell + ) + + def test_pending_field_renders_an_orange_warning(self): + for status, label in ( + (CustomFieldStatusChoices.STATUS_PROVISIONING, 'Provisioning'), + (CustomFieldStatusChoices.STATUS_DELETING, 'Deleting'), + ): + with self.subTest(status=status): + cell = self._row(status).get_cell('status') + + self.assertInHTML( + f'' + f'', + cell + ) + + def test_export_records_the_label(self): + """ + The icon carries no text, so an export must fall back to the human-readable status. + """ + for status, label in ( + (CustomFieldStatusChoices.STATUS_ACTIVE, 'Active'), + (CustomFieldStatusChoices.STATUS_PROVISIONING, 'Provisioning'), + (CustomFieldStatusChoices.STATUS_DELETING, 'Deleting'), + ): + with self.subTest(status=status): + self.assertEqual(self._row(status).get_cell_value('status'), label) + + class CustomFieldChoiceSetTableTestCase(TableTestCases.StandardTableTestCase): table = CustomFieldChoiceSetTable @@ -69,6 +135,39 @@ class EventRuleTableTestCase(TableTestCases.StandardTableTestCase): table = EventRuleTable +class EventRuleTableActionTypeRenderingTestCase(TestCase): + """ + render_action_type() badges an unregistered action as unavailable; value_action_type() carries + the same label for non-HTML output (e.g. CSV export), without the markup. + """ + + def test_render_action_type_for_registered_action(self): + rule = EventRule.objects.create(name='Render Test Rule', event_types=[OBJECT_CREATED], action_type='webhook') + rule.object_types.set([ObjectType.objects.get_for_model(Site)]) + + table = EventRuleTable(EventRule.objects.filter(pk=rule.pk)) + self.assertEqual(table.render_action_type(rule), 'Webhook') + self.assertEqual(table.value_action_type(rule), 'Webhook') + + def test_render_action_type_for_unregistered_action(self): + rule = EventRule.objects.create( + name='Render Test Unavailable Rule', + event_types=[OBJECT_CREATED], + action_type='someplugin.not_installed_render_test', + ) + rule.object_types.set([ObjectType.objects.get_for_model(Site)]) + + table = EventRuleTable(EventRule.objects.filter(pk=rule.pk)) + rendered = table.render_action_type(rule) + self.assertIn('someplugin.not_installed_render_test (unavailable)', rendered) + self.assertIn('badge text-bg-red', rendered) + + # The same label, without markup + value = table.value_action_type(rule) + self.assertEqual(value, 'someplugin.not_installed_render_test (unavailable)') + self.assertNotIn('= job_timeout: + logger.warning( + f"Webhook timeout ({timeout} seconds) is not less than the background job timeout ({job_timeout} " + f"seconds); the job may be terminated before the request can time out." + ) + # Send the request with requests.Session() as session: session.verify = webhook.ssl_verification if webhook.ca_file_path: session.verify = webhook.ca_file_path proxies = resolve_proxies(url=url, context={'client': webhook}) - response = session.send(prepared_request, proxies=proxies) + try: + response = session.send(prepared_request, proxies=proxies, timeout=timeout) + except requests.exceptions.Timeout: + logger.error(f"Request to {url} timed out after {timeout} seconds") + raise if 200 <= response.status_code <= 299: logger.info(f"Request succeeded; response status {response.status_code}") diff --git a/netbox/ipam/api/serializers_/services.py b/netbox/ipam/api/serializers_/services.py index 9d075dfb6..266213024 100644 --- a/netbox/ipam/api/serializers_/services.py +++ b/netbox/ipam/api/serializers_/services.py @@ -1,8 +1,13 @@ from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError as DjangoValidationError +from django.utils.translation import gettext as _ +from rest_framework import serializers from ipam.choices import * -from ipam.constants import SERVICE_ASSIGNMENT_MODELS +from ipam.constants import SERVICE_ASSIGNMENT_MODELS, SERVICE_PORT_MAX, SERVICE_PORT_MIN from ipam.models import IPAddress, Service, ServiceTemplate +from ipam.utils import legacy_protocol_and_ports +from ipam.validators import validate_port_mappings from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.gfk_fields import GFKSerializerField from netbox.api.serializers import PrimaryModelSerializer @@ -15,20 +20,126 @@ __all__ = ( ) -class ServiceTemplateSerializer(PrimaryModelSerializer): - protocol = ChoiceField(choices=ServiceProtocolChoices, required=False) +class PortMappingsField(serializers.ListField): + """ + A service's port mappings as a flat list of ``protocol/port`` strings (e.g. ``["tcp/80", "udp/53"]``), + matching how they are stored. Each entry is validated (and normalized) on write. + """ + child = serializers.CharField() + + def to_internal_value(self, data): + mappings = super().to_internal_value(data) + try: + return validate_port_mappings(mappings) + except DjangoValidationError as exc: + raise serializers.ValidationError(exc.messages) + + +class PortMappingsSerializerMixin(serializers.Serializer): + """ + Shared port-mapping handling for the Service and ServiceTemplate serializers, including backward + compatibility for the legacy single-protocol ``protocol``/``ports`` representation. + + Read: alongside the ``port_mappings`` list, a service that uses a single protocol also reports the + legacy ``protocol`` and ``ports`` fields; a multi-protocol service reports ``null`` for both (it + cannot be expressed in the old single-protocol format). + + Write: either format is accepted. When the legacy ``protocol``/``ports`` pair is supplied (and + ``port_mappings`` is not), it is translated into ``port_mappings``. Supplying both together is + accepted only when the legacy fields agree with what ``port_mappings`` implies (e.g. a full-object + round-trip that echoes back the read representation); a genuine conflict is rejected as ambiguous. + + Subclassing ``serializers.Serializer`` (rather than a plain mixin) lets DRF's metaclass collect the + fields declared here into the inheriting serializers. + """ + port_mappings = PortMappingsField(required=False) + + # Legacy single-protocol fields, retained for backward compatibility. They are read straight off the + # model's protocol/ports properties (which share these field names), so DRF sources them directly — + # matching the {"value", "label"} shape every other choice field uses. default=None applies only on + # write, where validate() consumes them. + # TODO: Remove protocol/ports in v5.0 along with the legacy handling in validate(). + protocol = ChoiceField( + choices=ServiceProtocolChoices, + required=False, + allow_null=True, + default=None, + help_text=_("Deprecated; use port_mappings. Reported only for single-protocol services."), + ) + ports = serializers.ListField( + child=serializers.IntegerField(min_value=SERVICE_PORT_MIN, max_value=SERVICE_PORT_MAX), + required=False, + allow_null=True, + default=None, + help_text=_("Deprecated; use port_mappings. Reported only for single-protocol services."), + ) + + def validate(self, data): + # Consume the legacy fields and translate them into port_mappings *before* calling super(), + # which instantiates the model (via full_clean()) and would choke on these now-nonexistent kwargs. + legacy_protocol = data.pop('protocol', None) + legacy_ports = data.pop('ports', None) + # protocol/ports carry default=None, so an omitted field arrives as None; an explicitly-supplied + # value (including a falsy ports=[]) is a legacy write and must be handled — checking `is not None` + # rather than truthiness so an intentional empty list isn't silently dropped. + if legacy_protocol is not None or legacy_ports is not None: + # `port_mappings` and `protocol`/`ports` are mutually exclusive as *representations*, but a + # full-object round-trip (GET then PUT/PATCH) legitimately resubmits port_mappings alongside + # the legacy protocol/ports the read emitted. Only reject a genuine *conflict*: when the legacy + # fields agree with what port_mappings already implies they're merely redundant, so accept the + # request and let port_mappings win. + if 'port_mappings' in data: + expected_protocol, expected_ports = legacy_protocol_and_ports(data['port_mappings']) + protocol_agrees = legacy_protocol is None or legacy_protocol == expected_protocol + ports_agree = legacy_ports is None or sorted(legacy_ports) == (expected_ports or []) + if not (protocol_agrees and ports_agree): + raise serializers.ValidationError(_( + "Specify either 'port_mappings' or the deprecated 'protocol'/'ports' fields, not both." + )) + return super().validate(data) + # The old API accepted an empty ports list (the ArrayField had no minimum length); the new + # model requires at least one mapping. Report that directly — both fields may have been + # supplied, so the "both are required" message below would be misleading. + if legacy_ports == []: + raise serializers.ValidationError( + {'ports': _("At least one port mapping is required.")} + ) + # The legacy API let either field be updated on its own (e.g. a PATCH that adjusts only the + # port list). Preserve that by backfilling the omitted field from the instance's current + # single-protocol representation. + if not (legacy_protocol and legacy_ports): + legacy_protocol = legacy_protocol or (self.instance.protocol if self.instance else None) + if legacy_ports is None: + legacy_ports = self.instance.ports if self.instance else None + # If the pair still can't be resolved — a create, or an existing multi-protocol service that + # has no single-protocol form — the request can't be expressed in the legacy format. + if not (legacy_protocol and legacy_ports): + raise serializers.ValidationError(_( + "Both 'protocol' and 'ports' are required when writing via the deprecated legacy " + "format; use port_mappings instead." + )) + try: + data['port_mappings'] = validate_port_mappings( + [f'{legacy_protocol}/{port}' for port in legacy_ports] + ) + except DjangoValidationError as exc: + raise serializers.ValidationError({'ports': exc.messages}) + + return super().validate(data) + + +class ServiceTemplateSerializer(PortMappingsSerializerMixin, PrimaryModelSerializer): class Meta: model = ServiceTemplate fields = [ - 'id', 'url', 'display_url', 'display', 'name', 'protocol', 'ports', 'description', 'owner', 'comments', - 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display_url', 'display', 'name', 'port_mappings', 'protocol', 'ports', 'description', + 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] - brief_fields = ('id', 'url', 'display', 'name', 'protocol', 'ports', 'description') + brief_fields = ('id', 'url', 'display', 'name', 'port_mappings', 'description') -class ServiceSerializer(PrimaryModelSerializer): - protocol = ChoiceField(choices=ServiceProtocolChoices, required=False) +class ServiceSerializer(PortMappingsSerializerMixin, PrimaryModelSerializer): ipaddresses = SerializedPKRelatedField( queryset=IPAddress.objects.all(), serializer=IPAddressSerializer, @@ -45,7 +156,7 @@ class ServiceSerializer(PrimaryModelSerializer): model = Service fields = [ 'id', 'url', 'display_url', 'display', 'parent_object_type', 'parent_object_id', 'parent', 'name', - 'protocol', 'ports', 'ipaddresses', 'description', 'owner', 'comments', 'tags', 'custom_fields', - 'created', 'last_updated', + 'port_mappings', 'protocol', 'ports', 'ipaddresses', 'description', 'owner', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', ] - brief_fields = ('id', 'url', 'display', 'name', 'protocol', 'ports', 'description') + brief_fields = ('id', 'url', 'display', 'name', 'port_mappings', 'description') diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 9024dbe5f..51be5e143 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -5,7 +5,7 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.db import router, transaction from django.shortcuts import get_object_or_404 from django.utils.translation import gettext as _ -from django_pglocks import advisory_lock +from django_pg_utils import advisory_lock from drf_spectacular.utils import extend_schema from netaddr import IPSet from rest_framework import status @@ -22,7 +22,7 @@ from netbox.api.viewsets import NetBoxModelViewSet from netbox.api.viewsets.mixins import ObjectValidationMixin, discard_events_on_rollback from netbox.config import get_config from netbox.constants import ADVISORY_LOCK_KEYS -from utilities.api import get_serializer_for_model +from utilities.api import get_positional_errors, get_serializer_for_model from virtualization.models import VMInterface from . import serializers @@ -265,8 +265,10 @@ class AvailableObjectsView(ObjectValidationMixin, APIView): **self.get_extra_context(parent), }) if not serializer.is_valid(): + # Report the errors by the position of each entry in the request, as the serializer is + # always bound to a list (a single object having been wrapped in one above) return Response( - serializer.errors, + get_positional_errors(serializer.errors, len(requested_objects)), status=status.HTTP_400_BAD_REQUEST ) @@ -292,7 +294,12 @@ class AvailableObjectsView(ObjectValidationMixin, APIView): serializer = serializer_class(data=requested_objects[0], context=context) if not serializer.is_valid(): - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + # A list request is reported by position; a single object carries no position, and + # its errors pass through unchanged + return Response( + get_positional_errors(serializer.errors, len(requested_objects)), + status=status.HTTP_400_BAD_REQUEST + ) # Create the new IP address(es) using = router.db_for_write(self.queryset.model) diff --git a/netbox/ipam/apps.py b/netbox/ipam/apps.py index f9310af8d..bfd79dad2 100644 --- a/netbox/ipam/apps.py +++ b/netbox/ipam/apps.py @@ -1,7 +1,5 @@ from django.apps import AppConfig -from netbox import denormalized - class IPAMConfig(AppConfig): name = "ipam" @@ -11,16 +9,6 @@ class IPAMConfig(AppConfig): from netbox.models.features import register_models from . import search, signals # noqa: F401 - from .models import Prefix # Register models register_models(*self.get_models()) - - # Register denormalized fields - denormalized.register(Prefix, '_site', { - '_region': 'region', - '_site_group': 'group', - }) - denormalized.register(Prefix, '_location', { - '_site': 'site', - }) diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index bc0d45b8a..71f324221 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -1,6 +1,6 @@ from django.utils.translation import gettext_lazy as _ -from utilities.choices import ChoiceSet +from utilities.choices import Choice, ChoiceSet class IPAddressFamilyChoices(ChoiceSet): @@ -9,8 +9,8 @@ class IPAddressFamilyChoices(ChoiceSet): FAMILY_6 = 6 CHOICES = ( - (FAMILY_4, 'IPv4'), - (FAMILY_6, 'IPv6'), + Choice(FAMILY_4, 'IPv4'), + Choice(FAMILY_6, 'IPv6'), ) @@ -27,10 +27,10 @@ class PrefixStatusChoices(ChoiceSet): STATUS_DEPRECATED = 'deprecated' CHOICES = [ - (STATUS_CONTAINER, _('Container'), 'gray'), - (STATUS_ACTIVE, _('Active'), 'blue'), - (STATUS_RESERVED, _('Reserved'), 'cyan'), - (STATUS_DEPRECATED, _('Deprecated'), 'red'), + Choice(STATUS_CONTAINER, _('Container'), color='gray', description=_('Organizes a set of child prefixes')), + Choice(STATUS_ACTIVE, _('Active'), color='blue', description=_('Provisioned and in use')), + Choice(STATUS_RESERVED, _('Reserved'), color='cyan', description=_('Designated for future use')), + Choice(STATUS_DEPRECATED, _('Deprecated'), color='red', description=_('No longer in use')), ] @@ -46,9 +46,9 @@ class IPRangeStatusChoices(ChoiceSet): STATUS_DEPRECATED = 'deprecated' CHOICES = [ - (STATUS_ACTIVE, _('Active'), 'blue'), - (STATUS_RESERVED, _('Reserved'), 'cyan'), - (STATUS_DEPRECATED, _('Deprecated'), 'red'), + Choice(STATUS_ACTIVE, _('Active'), color='blue', description=_('Provisioned and in use')), + Choice(STATUS_RESERVED, _('Reserved'), color='cyan', description=_('Designated for future use')), + Choice(STATUS_DEPRECATED, _('Deprecated'), color='red', description=_('No longer in use')), ] @@ -66,11 +66,16 @@ class IPAddressStatusChoices(ChoiceSet): STATUS_SLAAC = 'slaac' CHOICES = [ - (STATUS_ACTIVE, _('Active'), 'blue'), - (STATUS_RESERVED, _('Reserved'), 'cyan'), - (STATUS_DEPRECATED, _('Deprecated'), 'red'), - (STATUS_DHCP, _('DHCP'), 'purple'), - (STATUS_SLAAC, _('SLAAC'), 'purple'), + Choice(STATUS_ACTIVE, _('Active'), color='blue', description=_('Provisioned and in use')), + Choice(STATUS_RESERVED, _('Reserved'), color='cyan', description=_('Designated for future use')), + Choice(STATUS_DEPRECATED, _('Deprecated'), color='red', description=_('No longer in use')), + Choice(STATUS_DHCP, _('DHCP'), color='purple', description=_('Assigned dynamically via DHCP')), + Choice( + STATUS_SLAAC, + _('SLAAC'), + color='purple', + description=_('Assigned via IPv6 stateless address autoconfiguration') + ), ] @@ -86,14 +91,14 @@ class IPAddressRoleChoices(ChoiceSet): ROLE_CARP = 'carp' CHOICES = ( - (ROLE_LOOPBACK, _('Loopback'), 'gray'), - (ROLE_SECONDARY, _('Secondary'), 'blue'), - (ROLE_ANYCAST, _('Anycast'), 'yellow'), - (ROLE_VIP, 'VIP', 'purple'), - (ROLE_VRRP, 'VRRP', 'green'), - (ROLE_HSRP, 'HSRP', 'green'), - (ROLE_GLBP, 'GLBP', 'green'), - (ROLE_CARP, 'CARP', 'green'), + Choice(ROLE_LOOPBACK, _('Loopback'), color='gray', description=_('A loopback interface address')), + Choice(ROLE_SECONDARY, _('Secondary'), color='blue', description=_('A secondary address on an interface')), + Choice(ROLE_ANYCAST, _('Anycast'), color='yellow', description=_('An address shared among multiple nodes')), + Choice(ROLE_VIP, 'VIP', color='purple', description=_('A virtual IP address')), + Choice(ROLE_VRRP, 'VRRP', color='green', description=_('A virtual address managed by VRRP')), + Choice(ROLE_HSRP, 'HSRP', color='green', description=_('A virtual address managed by HSRP')), + Choice(ROLE_GLBP, 'GLBP', color='green', description=_('A virtual address managed by GLBP')), + Choice(ROLE_CARP, 'CARP', color='green', description=_('A virtual address managed by CARP')), ) @@ -113,18 +118,18 @@ class FHRPGroupProtocolChoices(ChoiceSet): CHOICES = ( (_('Standard'), ( - (PROTOCOL_VRRP2, 'VRRPv2'), - (PROTOCOL_VRRP3, 'VRRPv3'), - (PROTOCOL_CARP, 'CARP'), + Choice(PROTOCOL_VRRP2, 'VRRPv2', description=_('Virtual Router Redundancy Protocol version 2')), + Choice(PROTOCOL_VRRP3, 'VRRPv3', description=_('Virtual Router Redundancy Protocol version 3')), + Choice(PROTOCOL_CARP, 'CARP', description=_('Common Address Redundancy Protocol')), )), (_('CheckPoint'), ( - (PROTOCOL_CLUSTERXL, 'ClusterXL'), + Choice(PROTOCOL_CLUSTERXL, 'ClusterXL', description=_('Check Point ClusterXL high-availability protocol')), )), (_('Cisco'), ( - (PROTOCOL_HSRP, 'HSRP'), - (PROTOCOL_GLBP, 'GLBP'), + Choice(PROTOCOL_HSRP, 'HSRP', description=_('Hot Standby Router Protocol')), + Choice(PROTOCOL_GLBP, 'GLBP', description=_('Gateway Load Balancing Protocol')), )), - (PROTOCOL_OTHER, 'Other'), + Choice(PROTOCOL_OTHER, 'Other'), ) @@ -134,8 +139,8 @@ class FHRPGroupAuthTypeChoices(ChoiceSet): AUTHENTICATION_MD5 = 'md5' CHOICES = ( - (AUTHENTICATION_PLAINTEXT, _('Plaintext')), - (AUTHENTICATION_MD5, 'MD5'), + Choice(AUTHENTICATION_PLAINTEXT, _('Plaintext'), description=_('Authentication using a cleartext password')), + Choice(AUTHENTICATION_MD5, 'MD5', description=_('Authentication using an MD5 hash')), ) @@ -151,9 +156,9 @@ class VLANStatusChoices(ChoiceSet): STATUS_DEPRECATED = 'deprecated' CHOICES = [ - (STATUS_ACTIVE, _('Active'), 'blue'), - (STATUS_RESERVED, _('Reserved'), 'cyan'), - (STATUS_DEPRECATED, _('Deprecated'), 'red'), + Choice(STATUS_ACTIVE, _('Active'), color='blue', description=_('Provisioned and in use')), + Choice(STATUS_RESERVED, _('Reserved'), color='cyan', description=_('Designated for future use')), + Choice(STATUS_DEPRECATED, _('Deprecated'), color='red', description=_('No longer in use')), ] @@ -163,8 +168,8 @@ class VLANQinQRoleChoices(ChoiceSet): ROLE_CUSTOMER = 'cvlan' CHOICES = [ - (ROLE_SERVICE, _('Service'), 'blue'), - (ROLE_CUSTOMER, _('Customer'), 'orange'), + Choice(ROLE_SERVICE, _('Service'), color='blue', description=_('An outer service VLAN (S-VLAN)')), + Choice(ROLE_CUSTOMER, _('Customer'), color='orange', description=_('An inner customer VLAN (C-VLAN)')), ] @@ -179,7 +184,7 @@ class ServiceProtocolChoices(ChoiceSet): PROTOCOL_SCTP = 'sctp' CHOICES = ( - (PROTOCOL_TCP, 'TCP'), - (PROTOCOL_UDP, 'UDP'), - (PROTOCOL_SCTP, 'SCTP'), + Choice(PROTOCOL_TCP, 'TCP'), + Choice(PROTOCOL_UDP, 'UDP'), + Choice(PROTOCOL_SCTP, 'SCTP'), ) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 9f190d023..b64f93bc0 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -22,7 +22,6 @@ from utilities.filters import ( MultiValueCharFilter, MultiValueContentTypeFilter, MultiValueNumberFilter, - NumericArrayFilter, TreeNodeMultipleChoiceFilter, ) from utilities.filtersets import register_filterset @@ -31,6 +30,7 @@ from vpn.models import L2VPN from .choices import * from .models import * +from .utils import normalize_port_mapping, port_mapping_q __all__ = ( 'ASNFilterSet', @@ -1214,16 +1214,146 @@ class VLANTranslationRuleFilterSet(NetBoxModelFilterSet): return queryset.filter(qs_filter) -@register_filterset -class ServiceTemplateFilterSet(PrimaryModelFilterSet): - port = NumericArrayFilter( - field_name='ports', - lookup_expr='contains' +# Service/ServiceTemplate port filter name -> the port lookup it applies, in the order the conditions +# are built. See ServicePortMappingFilterMixin and ipam.utils.PORT_MAPPING_LOOKUPS. +SERVICE_PORT_FILTERS = { + 'port': 'exact', + 'port__gt': 'gt', + 'port__gte': 'gte', + 'port__lt': 'lt', + 'port__lte': 'lte', +} + + +class ServicePortMappingFilterMixin(django_filters.FilterSet): + """ + Shared ``port_mappings``, ``protocol`` and ``port`` filtering for Service and ServiceTemplate, all + operating on the ``port_mappings`` array. ``protocol`` and every active ``port`` lookup are + correlated: they must all be satisfied by one single mapping, so ``?protocol=tcp&port__gt=1000`` does + not match a service whose only tcp mapping is tcp/80, and ``?port__gte=1000&port__lte=2000`` does not + match a service exposing only ports 500 and 5000. See ``ipam.utils.port_mapping_q``. + """ + # Whole-mapping lookup, e.g. ?port_mappings=tcp/80. Each value already names one complete + # protocol/port pair, so this needs none of the protocol/port correlation machinery below and is + # simply ANDed with the other filters. + port_mappings = MultiValueCharFilter( + method='filter_port_mappings', + label=_('Port mapping (protocol/port)'), ) + port_mappings__n = MultiValueCharFilter( + method='filter_port_mappings_negated', + label=_('Port mapping (protocol/port)'), + ) + protocol = django_filters.MultipleChoiceFilter( + choices=ServiceProtocolChoices, + method='filter_protocol_noop', + ) + # Negation lookup retained from when `protocol` was a model field: method-based filters don't get + # the char-based lookups (protocol__n, __ic, ...) auto-generated, and silently dropping protocol__n + # would widen existing saved filters/scripts rather than error. The __ic/__nic/__empty variants were + # never meaningful on a small fixed choice set and are intentionally left gone. + protocol__n = django_filters.MultipleChoiceFilter( + choices=ServiceProtocolChoices, + method='filter_protocol_negated', + ) + # `port` and its range lookups. These are declared explicitly because a method-based filter gets no + # auto-generated lookups (BaseFilterSet.get_additional_lookups() skips filters with a method), and + # they must be correlated with `protocol` rather than applied independently. `port__empty` is + # intentionally absent: port_mappings is never empty on a validated object, so it was never + # meaningful. See ipam.utils.PORT_MAPPING_LOOKUPS for the lookup -> SQL operator mapping. + # + # `protocol` above and every `port*` lookup below (except the negations, which stand alone) are + # deliberately no-ops: because they must be correlated with one another they cannot be applied as each + # filter runs. filter_queryset() applies them together, once, after super() has applied the rest. + port = MultiValueNumberFilter( + method='filter_noop', + ) + port__n = MultiValueNumberFilter( + method='filter_port_negated', + ) + port__gt = MultiValueNumberFilter( + method='filter_noop', + ) + port__gte = MultiValueNumberFilter( + method='filter_noop', + ) + port__lt = MultiValueNumberFilter( + method='filter_noop', + ) + port__lte = MultiValueNumberFilter( + method='filter_noop', + ) + + def filter_queryset(self, queryset): + """ + Apply `protocol` and every active `port*` lookup as a single correlated predicate. + + These can't be applied per-filter the way django-filter normally works: they must all be satisfied + by one single mapping, and a query combining N of them would otherwise emit N independent (and + redundant) copies of the same scan. So the individual filters are no-ops and the combined + predicate is built here, from the cleaned data, exactly once per call. + """ + queryset = super().filter_queryset(queryset) + + cleaned_data = self.form.cleaned_data + protocols = cleaned_data.get('protocol') or [] + port_tests = [ + (lookup, values) + for lookup, values in ( + (lookup, cleaned_data.get(name) or []) + for name, lookup in SERVICE_PORT_FILTERS.items() + ) + if values + ] + if not protocols and not port_tests: + return queryset + + return queryset.filter(port_mapping_q(protocols, port_tests)) + + def filter_noop(self, queryset, name, value): + # See filter_queryset(), which applies `protocol` and the port lookups as one correlated predicate. + return queryset + + def filter_protocol_noop(self, queryset, name, value: list[str]): + # A no-op like filter_noop(), from which this differs only in its type hint. `protocol` is not a + # model field, so the schema generator cannot infer the parameter's type from the model, and a + # plain MultipleChoiceFilter carries none of the @extend_schema_field annotations which the + # MultiValue* filters (used by the port lookups) do. The hint supplies it. + return self.filter_noop(queryset, name, value) + + def filter_port_mappings(self, queryset, name, value: list[str]): + # Array overlap (&&) is served by the GIN index on port_mappings and gives the multi-value OR + # semantics used throughout NetBox: ?port_mappings=tcp/80&port_mappings=udp/53 matches either. + if not value: + return queryset + return queryset.filter(port_mappings__overlap=[normalize_port_mapping(v) for v in value]) + + def filter_port_mappings_negated(self, queryset, name, value: list[str]): + if not value: + return queryset + return queryset.exclude(port_mappings__overlap=[normalize_port_mapping(v) for v in value]) + + def filter_protocol_negated(self, queryset, name, value: list[str]): + # Exclude services exposing any of the given protocols (negation of the protocol-only lookup). + if not value: + return queryset + return queryset.exclude(port_mapping_q(value)) + + def filter_port_negated(self, queryset, name, value: list[int]): + # Exclude services exposing any of the given ports. Correlated with `protocol` when supplied, so + # ?protocol=tcp&port__n=80 excludes only services exposing tcp/80 (not those exposing udp/80). + if not value: + return queryset + protocols = self.form.cleaned_data.get('protocol') or [] + return queryset.exclude(port_mapping_q(protocols, [('exact', value)])) + + +@register_filterset +class ServiceTemplateFilterSet(ServicePortMappingFilterMixin, PrimaryModelFilterSet): class Meta: model = ServiceTemplate - fields = ('id', 'name', 'protocol', 'description') + fields = ('id', 'name', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -1236,7 +1366,7 @@ class ServiceTemplateFilterSet(PrimaryModelFilterSet): @register_filterset -class ServiceFilterSet(ContactModelFilterSet, PrimaryModelFilterSet): +class ServiceFilterSet(ServicePortMappingFilterMixin, ContactModelFilterSet, PrimaryModelFilterSet): parent_object_type = MultiValueContentTypeFilter() device = MultiValueCharFilter( method='filter_device', @@ -1279,14 +1409,10 @@ class ServiceFilterSet(ContactModelFilterSet, PrimaryModelFilterSet): to_field_name='address', label=_('IP address'), ) - port = NumericArrayFilter( - field_name='ports', - lookup_expr='contains' - ) class Meta: model = Service - fields = ('id', 'name', 'protocol', 'description', 'parent_object_type', 'parent_object_id') + fields = ('id', 'name', 'description', 'parent_object_type', 'parent_object_id') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/ipam/forms/__init__.py b/netbox/ipam/forms/__init__.py index 5cec11aac..f5ae3bca5 100644 --- a/netbox/ipam/forms/__init__.py +++ b/netbox/ipam/forms/__init__.py @@ -1,5 +1,7 @@ from .bulk_create import * from .bulk_edit import * from .bulk_import import * +from .fields import * from .filtersets import * from .model_forms import * +from .widgets import * diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index d5ad48693..fe16c143b 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -6,20 +6,21 @@ from dcim.forms.mixins import ScopedBulkEditForm from dcim.models import Region, Site, SiteGroup from ipam.choices import * from ipam.constants import * +from ipam.forms.fields import PortMappingField from ipam.models import * from ipam.models import ASN from netbox.forms import NetBoxModelBulkEditForm, OrganizationalModelBulkEditForm, PrimaryModelBulkEditForm from tenancy.models import Tenant -from utilities.forms import add_blank_choice +from utilities.forms import GenericObjectFormMixin, add_blank_choice from utilities.forms.fields import ( - ContentTypeChoiceField, + ChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, - NumericArrayField, + GenericObjectChoiceField, NumericRangeArrayField, ) from utilities.forms.rendering import FieldSet -from utilities.forms.widgets import BulkEditNullBooleanSelect, HTMXSelect +from utilities.forms.widgets import BulkEditNullBooleanSelect __all__ = ( 'ASNBulkEditForm', @@ -203,7 +204,7 @@ class PrefixBulkEditForm(ScopedBulkEditForm, PrimaryModelBulkEditForm): queryset=Tenant.objects.all(), required=False ) - status = forms.ChoiceField( + status = ChoiceField( label=_('Status'), choices=add_blank_choice(PrefixStatusChoices), required=False @@ -228,7 +229,7 @@ class PrefixBulkEditForm(ScopedBulkEditForm, PrimaryModelBulkEditForm): fieldsets = ( FieldSet('tenant', 'status', 'role', 'description'), FieldSet('vrf', 'prefix_length', 'is_pool', 'mark_utilized', name=_('Addressing')), - FieldSet('scope_type', 'scope', name=_('Scope')), + FieldSet('scope', name=_('Scope')), FieldSet('vlan_group', 'vlan', name=_('VLAN Assignment')), ) nullable_fields = ( @@ -247,7 +248,7 @@ class IPRangeBulkEditForm(PrimaryModelBulkEditForm): queryset=Tenant.objects.all(), required=False ) - status = forms.ChoiceField( + status = ChoiceField( label=_('Status'), choices=add_blank_choice(IPRangeStatusChoices), required=False @@ -294,12 +295,12 @@ class IPAddressBulkEditForm(PrimaryModelBulkEditForm): queryset=Tenant.objects.all(), required=False ) - status = forms.ChoiceField( + status = ChoiceField( label=_('Status'), choices=add_blank_choice(IPAddressStatusChoices), required=False ) - role = forms.ChoiceField( + role = ChoiceField( label=_('Role'), choices=add_blank_choice(IPAddressRoleChoices), required=False @@ -321,7 +322,7 @@ class IPAddressBulkEditForm(PrimaryModelBulkEditForm): class FHRPGroupBulkEditForm(PrimaryModelBulkEditForm): - protocol = forms.ChoiceField( + protocol = ChoiceField( label=_('Protocol'), choices=add_blank_choice(FHRPGroupProtocolChoices), required=False @@ -331,7 +332,7 @@ class FHRPGroupBulkEditForm(PrimaryModelBulkEditForm): required=False, label=_('Group ID') ) - auth_type = forms.ChoiceField( + auth_type = ChoiceField( choices=add_blank_choice(FHRPGroupAuthTypeChoices), required=False, label=_('Authentication type') @@ -355,13 +356,13 @@ class FHRPGroupBulkEditForm(PrimaryModelBulkEditForm): nullable_fields = ('auth_type', 'auth_key', 'name', 'description', 'comments') -class VLANGroupBulkEditForm(ScopedBulkEditForm, OrganizationalModelBulkEditForm): - # Override ScopedBulkEditForm.scope_type to set custom queryset - scope_type = ContentTypeChoiceField( - queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), - widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}), +class VLANGroupBulkEditForm(GenericObjectFormMixin, OrganizationalModelBulkEditForm): + scope = GenericObjectChoiceField( + label=_('Scope'), + content_type_queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), required=False, - label=_('Scope type') + selector=True, + hx_method='post', ) vid_ranges = NumericRangeArrayField( label=_('VLAN ID ranges'), @@ -376,7 +377,7 @@ class VLANGroupBulkEditForm(ScopedBulkEditForm, OrganizationalModelBulkEditForm) model = VLANGroup fieldsets = ( FieldSet('site', 'vid_ranges', 'description'), - FieldSet('scope_type', 'scope', name=_('Scope')), + FieldSet('scope', name=_('Scope')), FieldSet('tenant', name=_('Tenancy')), ) nullable_fields = ('description', 'scope', 'comments') @@ -415,7 +416,7 @@ class VLANBulkEditForm(PrimaryModelBulkEditForm): queryset=Tenant.objects.all(), required=False ) - status = forms.ChoiceField( + status = ChoiceField( label=_('Status'), choices=add_blank_choice(VLANStatusChoices), required=False @@ -425,7 +426,7 @@ class VLANBulkEditForm(PrimaryModelBulkEditForm): queryset=Role.objects.all(), required=False ) - qinq_role = forms.ChoiceField( + qinq_role = ChoiceField( label=_('Q-in-Q role'), choices=add_blank_choice(VLANQinQRoleChoices), required=False @@ -475,23 +476,20 @@ class VLANTranslationRuleBulkEditForm(NetBoxModelBulkEditForm): class ServiceTemplateBulkEditForm(PrimaryModelBulkEditForm): - protocol = forms.ChoiceField( - label=_('Protocol'), - choices=add_blank_choice(ServiceProtocolChoices), - required=False + add_port_mappings = PortMappingField( + label=_('Add port mappings'), + required=False, + help_text=_("Port mappings to add to each selected object"), ) - ports = NumericArrayField( - label=_('Ports'), - base_field=forms.IntegerField( - min_value=SERVICE_PORT_MIN, - max_value=SERVICE_PORT_MAX - ), - required=False + remove_port_mappings = PortMappingField( + label=_('Remove port mappings'), + required=False, + help_text=_("Port mappings to remove from each selected object (if present)"), ) model = ServiceTemplate fieldsets = ( - FieldSet('protocol', 'ports', 'description'), + FieldSet('add_port_mappings', 'remove_port_mappings', 'description'), ) nullable_fields = ('description', 'comments') diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 43a679f84..488014f02 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -1,5 +1,7 @@ from django import forms from django.contrib.contenttypes.models import ContentType +from django.contrib.postgres.forms import SimpleArrayField +from django.core.exceptions import ValidationError as DjangoValidationError from django.utils.translation import gettext_lazy as _ from dcim.forms.mixins import ScopedImportForm @@ -7,6 +9,8 @@ from dcim.models import Device, Interface, Site from ipam.choices import * from ipam.constants import * from ipam.models import * +from ipam.utils import expand_port_mapping, split_port_mapping +from ipam.validators import validate_port_mappings from netbox.forms import NetBoxModelImportForm, OrganizationalModelImportForm, PrimaryModelImportForm from tenancy.models import Tenant from utilities.forms.fields import ( @@ -586,19 +590,49 @@ class VLANTranslationRuleImportForm(NetBoxModelImportForm): fields = ('policy', 'local_vid', 'remote_vid') -class ServiceTemplateImportForm(PrimaryModelImportForm): - protocol = CSVChoiceField( - label=_('Protocol'), - choices=ServiceProtocolChoices, - help_text=_('IP protocol') +class ServicePortMappingsImportMixin(forms.Form): + """ + Adds a ``port_mappings`` CSV column parsed from a comma-separated list of ``protocol/port`` pairs + (e.g. "tcp/80,udp/53") into the model's flat ``['tcp/80', 'udp/53']`` list. A pair's port half may be + a hyphen range (e.g. "tcp/8000-8010"), matching the port syntax the edit form accepts. + """ + port_mappings = SimpleArrayField( + base_field=forms.CharField(), + label=_('Port mappings'), + required=True, + help_text=_('Comma-separated list of protocol/port pairs in double quotes (e.g. "tcp/80,udp/53"). ' + 'A port range may be given with a hyphen (e.g. "tcp/8000-8010").') ) + def clean_port_mappings(self): + mappings = self.cleaned_data.get('port_mappings') + if not mappings: + return [] + # Expand any hyphen range in a pair's port half (tcp/8000-8010 -> tcp/8000, tcp/8001, ...) so the + # CSV accepts the same port syntax as the edit form. validate_port_mappings then normalizes and + # checks each expanded pair, matching the protocol case-insensitively. + expanded = [] + for mapping in mappings: + protocol, ports = split_port_mapping(mapping.strip()) + try: + expanded.extend(expand_port_mapping(protocol, ports)) + except DjangoValidationError as exc: + raise forms.ValidationError(exc.messages) + try: + expanded = validate_port_mappings(expanded) + except DjangoValidationError as exc: + raise forms.ValidationError(exc.messages) + return expanded + + +class ServiceTemplateImportForm(ServicePortMappingsImportMixin, PrimaryModelImportForm): + class Meta: model = ServiceTemplate - fields = ('name', 'protocol', 'ports', 'description', 'owner', 'comments', 'tags') + fields = ('name', 'port_mappings', 'description', 'owner', 'comments', 'tags') -class ServiceImportForm(PrimaryModelImportForm): +class ServiceImportForm(ServicePortMappingsImportMixin, PrimaryModelImportForm): parent_object_type = CSVContentTypeField( queryset=ContentType.objects.filter(SERVICE_ASSIGNMENT_MODELS), required=True, @@ -615,11 +649,6 @@ class ServiceImportForm(PrimaryModelImportForm): required=False, help_text=_('Parent object ID'), ) - protocol = CSVChoiceField( - label=_('Protocol'), - choices=ServiceProtocolChoices, - help_text=_('IP protocol') - ) ipaddresses = CSVModelMultipleChoiceField( queryset=IPAddress.objects.all(), required=False, @@ -630,7 +659,7 @@ class ServiceImportForm(PrimaryModelImportForm): class Meta: model = Service fields = ( - 'ipaddresses', 'name', 'protocol', 'ports', 'description', 'owner', 'comments', 'tags', + 'ipaddresses', 'name', 'port_mappings', 'description', 'owner', 'comments', 'tags', ) def __init__(self, data=None, *args, **kwargs): diff --git a/netbox/ipam/forms/fields.py b/netbox/ipam/forms/fields.py new file mode 100644 index 000000000..a8ec23dd9 --- /dev/null +++ b/netbox/ipam/forms/fields.py @@ -0,0 +1,103 @@ +import json + +from django import forms +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + +from ipam.forms.widgets import PortMappingWidget +from ipam.utils import expand_port_mapping, group_port_mapping_rows +from ipam.validators import validate_port_mappings + +__all__ = ( + 'PortMappingField', +) + + +class PortMappingField(forms.Field): + """ + A form field for editing a service's port mappings. Presents one row per protocol (each with a + comma/range list of ports) but cleans to the model's flat list of ``protocol/port`` strings, e.g. + ``['tcp/80', 'tcp/443', 'udp/53']``. + """ + widget = PortMappingWidget + + def prepare_value(self, value): + # Group the flat ['tcp/80', 'tcp/443', 'udp/53'] list back into per-protocol rows for the widget. + if value in (None, ''): + return '[]' + if isinstance(value, str): + # An already-grouped JSON string (e.g. re-rendering a bound form) is passed through. A bare + # 'protocol/port' string arrives when cloning a single-mapping object: the querystring + # single-value collapse (normalize_querydict) yields a str rather than a list, so group it + # like the list case instead of handing the widget unparseable JSON (which blanks the row). + try: + json.loads(value) + except (TypeError, ValueError): + return json.dumps(group_port_mapping_rows([value])) + return value + return json.dumps(group_port_mapping_rows(value)) + + def to_python(self, value): + if value in (None, ''): + return [] + # A list is assumed to already be the flat ['tcp/80', ...] form (e.g. set programmatically) + if isinstance(value, list): + mappings = value + else: + try: + rows = json.loads(value) + except (TypeError, ValueError): + raise ValidationError(_("Invalid port mapping data.")) + if not isinstance(rows, list): + raise ValidationError(_("Invalid port mapping data.")) + + mappings = [] + for position, row in enumerate(rows, start=1): + # The widget's JS always submits a list of {protocol, ports} objects, but the hidden + # input is just POST data: a hand-crafted payload can put anything here, so validate the + # shape rather than letting a non-dict row raise AttributeError (a 500) on .get() below. + if not isinstance(row, dict): + raise ValidationError(_("Invalid port mapping data.")) + protocol = row.get('protocol') + raw_ports = row.get('ports') + # Likewise `protocol` is only ever a string, and `ports` either a string (the widget's + # comma/range format) or a list of ports (set programmatically); anything else would reach + # expand_port_mapping() and fail there on .strip(). + if ( + (protocol is not None and not isinstance(protocol, str)) + or (raw_ports is not None and not isinstance(raw_ports, (str, list))) + ): + raise ValidationError(_("Invalid port mapping data.")) + if isinstance(raw_ports, str): + raw_ports = raw_ports.strip() + # Ignore entirely-empty rows (e.g. the default blank row on an untouched form) + if not protocol and not raw_ports: + continue + # Expand via the shared helper, which accepts either the widget's comma/range string or an + # already-expanded list, rejects a blank protocol, and preserves a protocol-without-ports + # row as a bare 'protocol/' token. Errors are re-raised with the row's position (among the + # submitted rows — the widget omits entirely-blank ones), since it renders one row per + # protocol and an unqualified "must specify a protocol" gives no clue which row to fix. + # Errors from validate_port_mappings() below are deliberately left unqualified: each quotes + # the offending mapping already, and a duplicate spans two rows. + try: + mappings.extend(expand_port_mapping(protocol, raw_ports)) + except ValidationError as e: + raise ValidationError([ + _("Row {position}: {error}").format(position=position, error=message) + for message in e.messages + ]) + + # Shared validation returns the canonical (normalized) list of protocol/port strings + return validate_port_mappings(mappings) + + def has_changed(self, initial, data): + # Compare the parsed mappings rather than raw strings, so cosmetic differences (row/port + # ordering, whitespace) don't register as a change. + def normalize(value): + try: + return sorted(self.to_python(value)) + except ValidationError: + return None + + return normalize(self.prepare_value(initial)) != normalize(data) diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 0bd60ef4f..925a153e9 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -640,12 +640,24 @@ class ServiceTemplateFilterForm(PrimaryModelFilterSetForm): model = ServiceTemplate fieldsets = ( FieldSet('q', 'filter_id', 'tag'), - FieldSet('protocol', 'port', name=_('Attributes')), + FieldSet('port_mappings', 'protocol', 'port', name=_('Attributes')), FieldSet('owner_group_id', 'owner_id', name=_('Ownership')), ) - protocol = forms.ChoiceField( + # A complete protocol/port pair, matched as a whole. Unlike `protocol` and `port` (which are + # correlated but independently specified), this is a single free-text value: an unknown protocol or + # malformed pair simply matches nothing, so no client-side validation is needed here. + port_mappings = forms.CharField( + label=_('Port mapping'), + required=False, + widget=forms.TextInput( + attrs={ + 'placeholder': 'e.g. tcp/80', + } + ) + ) + protocol = forms.MultipleChoiceField( label=_('Protocol'), - choices=add_blank_choice(ServiceProtocolChoices), + choices=ServiceProtocolChoices, required=False ) port = forms.IntegerField( @@ -659,7 +671,7 @@ class ServiceFilterForm(ContactModelFilterForm, ServiceTemplateFilterForm): model = Service fieldsets = ( FieldSet('q', 'filter_id', 'tag'), - FieldSet('protocol', 'port', name=_('Attributes')), + FieldSet('port_mappings', 'protocol', 'port', name=_('Attributes')), FieldSet('device_id', 'virtual_machine_id', 'fhrpgroup_id', name=_('Assignment')), FieldSet('owner_group_id', 'owner_id', name=_('Ownership')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 5fa7ac5b7..eec418b3b 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -1,6 +1,6 @@ from django import forms from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.core.exceptions import ValidationError from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ @@ -9,22 +9,22 @@ from dcim.models import Device, Interface, Site, SiteGroup from ipam.choices import * from ipam.constants import * from ipam.formfields import IPNetworkFormField +from ipam.forms.fields import PortMappingField from ipam.models import * from netbox.forms import NetBoxModelForm, OrganizationalModelForm, PrimaryModelForm from tenancy.forms import TenancyForm from utilities.exceptions import PermissionsViolation -from utilities.forms import add_blank_choice +from utilities.forms import GenericObjectFormMixin, add_blank_choice from utilities.forms.fields import ( - ContentTypeChoiceField, + ChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, - NumericArrayField, + GenericObjectChoiceField, NumericRangeArrayField, + TypedChoiceField, ) -from utilities.forms.rendering import FieldSet, InlineFields, ObjectAttribute, TabbedGroups -from utilities.forms.utils import get_field_value -from utilities.forms.widgets import DatePicker, HTMXSelect -from utilities.templatetags.builtins.filters import bettertitle +from utilities.forms.rendering import FieldSet, ObjectAttribute, TabbedGroups +from utilities.forms.widgets import DatePicker from virtualization.models import VirtualMachine, VMInterface __all__ = ( @@ -205,6 +205,12 @@ class RoleForm(OrganizationalModelForm): class PrefixForm(TenancyForm, ScopedForm, PrimaryModelForm): + status = ChoiceField( + label=_('Status'), + choices=PrefixStatusChoices, + initial=PrefixStatusChoices.STATUS_ACTIVE, + help_text=_('Operational status of this prefix'), + ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, @@ -215,7 +221,7 @@ class PrefixForm(TenancyForm, ScopedForm, PrimaryModelForm): required=False, selector=True, query_params={ - 'available_at_site': '$scope', + 'available_at_site': '$scope_object_id', }, label=_('VLAN'), ) @@ -230,7 +236,7 @@ class PrefixForm(TenancyForm, ScopedForm, PrimaryModelForm): FieldSet( 'prefix', 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', name=_('Prefix') ), - FieldSet('scope_type', 'scope', name=_('Scope')), + FieldSet('scope', name=_('Scope'), html_id='scope'), FieldSet('vlan', name=_('VLAN Assignment')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) @@ -238,24 +244,24 @@ class PrefixForm(TenancyForm, ScopedForm, PrimaryModelForm): class Meta: model = Prefix fields = [ - 'prefix', 'vrf', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'scope_type', 'tenant_group', + 'prefix', 'vrf', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'tenant_group', 'tenant', 'description', 'owner', 'comments', 'tags', ] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # #18605: only filter VLAN select list if scope field is a Site or Site Group + # #18605: only filter the VLAN select list if the selected scope is a Site (or none is selected yet). + # #22588: a Site Group scope filters VLANs by the group's member sites instead. if scope_field := self.fields.get('scope', None): - if scope_field.queryset.model is Site: - pass # already filtered by available_at_site - elif scope_field.queryset.model is SiteGroup: + selected_model = scope_field.selected_model + if selected_model is SiteGroup: self.fields['vlan'].widget.dynamic_params.clear() self.fields['vlan'].widget.attrs.pop('data-dynamic-params', None) self.fields['vlan'].widget.add_query_params({ - 'available_at_site_group': '$scope', + 'available_at_site_group': '$scope_object_id', }) - else: + elif selected_model not in (None, Site): self.fields['vlan'].widget.attrs.pop('data-dynamic-params', None) @@ -270,13 +276,19 @@ class PrefixBulkAddForm(PrefixForm): FieldSet( 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', name=_('Prefix') ), - FieldSet('scope_type', 'scope', name=_('Scope')), + FieldSet('scope', name=_('Scope'), html_id='scope'), FieldSet('vlan', name=_('VLAN Assignment')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) class IPRangeForm(TenancyForm, PrimaryModelForm): + status = ChoiceField( + label=_('Status'), + choices=IPRangeStatusChoices, + initial=IPRangeStatusChoices.STATUS_ACTIVE, + help_text=_('Operational status of this range'), + ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, @@ -306,6 +318,18 @@ class IPRangeForm(TenancyForm, PrimaryModelForm): class IPAddressForm(TenancyForm, PrimaryModelForm): + status = ChoiceField( + label=_('Status'), + choices=IPAddressStatusChoices, + initial=IPAddressStatusChoices.STATUS_ACTIVE, + help_text=_('The operational status of this IP'), + ) + role = TypedChoiceField( + label=_('Role'), + choices=add_blank_choice(IPAddressRoleChoices), + required=False, + help_text=_('The functional role of this IP'), + ) interface = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, @@ -493,6 +517,18 @@ class IPAddressForm(TenancyForm, PrimaryModelForm): class IPAddressBulkAddForm(TenancyForm, PrimaryModelForm): + status = ChoiceField( + label=_('Status'), + choices=IPAddressStatusChoices, + initial=IPAddressStatusChoices.STATUS_ACTIVE, + help_text=_('The operational status of this IP'), + ) + role = TypedChoiceField( + label=_('Role'), + choices=add_blank_choice(IPAddressRoleChoices), + required=False, + help_text=_('The functional role of this IP'), + ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, @@ -525,6 +561,15 @@ class IPAddressAssignForm(forms.Form): class FHRPGroupForm(PrimaryModelForm): + protocol = ChoiceField( + label=_('Protocol'), + choices=FHRPGroupProtocolChoices, + ) + auth_type = TypedChoiceField( + label=_('Authentication type'), + choices=add_blank_choice(FHRPGroupAuthTypeChoices), + required=False, + ) # Optionally create a new IPAddress along with the FHRPGroup ip_vrf = DynamicModelChoiceField( @@ -536,7 +581,7 @@ class FHRPGroupForm(PrimaryModelForm): required=False, label=_('Address') ) - ip_status = forms.ChoiceField( + ip_status = ChoiceField( choices=add_blank_choice(IPAddressStatusChoices), required=False, label=_('Status') @@ -628,34 +673,46 @@ class FHRPGroupAssignmentForm(forms.ModelForm): return group -class VLANGroupForm(TenancyForm, ScopedForm, OrganizationalModelForm): +class VLANGroupForm(GenericObjectFormMixin, TenancyForm, OrganizationalModelForm): vid_ranges = NumericRangeArrayField( label=_('VLAN IDs') ) - # Override ScopedForm.scope_type to set custom queryset - scope_type = ContentTypeChoiceField( - queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), - widget=HTMXSelect(), + scope = GenericObjectChoiceField( + label=_('Scope'), + content_type_queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), required=False, - label=_('Scope type') + selector=True, + hx_target_id='scope', ) fieldsets = ( FieldSet('name', 'slug', 'description', 'tags', name=_('VLAN Group')), FieldSet('vid_ranges', name=_('Child VLANs')), - FieldSet('scope_type', 'scope', name=_('Scope')), + FieldSet('scope', name=_('Scope'), html_id='scope'), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) class Meta: model = VLANGroup fields = [ - 'name', 'slug', 'description', 'vid_ranges', 'scope_type', 'tenant_group', 'tenant', 'owner', 'comments', + 'name', 'slug', 'description', 'vid_ranges', 'tenant_group', 'tenant', 'owner', 'comments', 'tags', ] class VLANForm(TenancyForm, PrimaryModelForm): + status = ChoiceField( + label=_('Status'), + choices=VLANStatusChoices, + initial=VLANStatusChoices.STATUS_ACTIVE, + help_text=_('Operational status of this VLAN'), + ) + qinq_role = TypedChoiceField( + label=_('Q-in-Q role'), + choices=add_blank_choice(VLANQinQRoleChoices), + required=False, + help_text=_('Customer/service VLAN designation (for Q-in-Q/IEEE 802.1ad)'), + ) group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), required=False, @@ -751,46 +808,37 @@ class VLANTranslationRuleForm(NetBoxModelForm): ] -class ServiceTemplateForm(PrimaryModelForm): - ports = NumericArrayField( - label=_('Ports'), - base_field=forms.IntegerField( - min_value=SERVICE_PORT_MIN, - max_value=SERVICE_PORT_MAX +class ServicePortMappingsMixin(forms.Form): + """ + Adds a ``port_mappings`` field (protocol + ports rows) to a Service/ServiceTemplate form. The field + maps directly to the model's ``port_mappings`` ArrayField, so no custom save handling is required. + """ + port_mappings = PortMappingField( + label=_('Port Mappings'), + help_text=_( + "One protocol per row, each with one or more port numbers. A range may be specified using a " + "hyphen (e.g. 80,443,8000-8010)." ), - help_text=_("Comma-separated list of one or more port numbers. A range may be specified using a hyphen.") ) + +class ServiceTemplateForm(ServicePortMappingsMixin, PrimaryModelForm): fieldsets = ( - FieldSet('name', 'protocol', 'ports', 'description', 'tags', name=_('Application Service Template')), + FieldSet('name', 'port_mappings', 'description', 'tags', name=_('Application Service Template')), ) class Meta: model = ServiceTemplate - fields = ('name', 'protocol', 'ports', 'description', 'owner', 'comments', 'tags') + fields = ('name', 'port_mappings', 'description', 'owner', 'comments', 'tags') -class ServiceForm(PrimaryModelForm): - parent_object_type = ContentTypeChoiceField( - queryset=ContentType.objects.filter(SERVICE_ASSIGNMENT_MODELS), - widget=HTMXSelect(), - required=True, - label=_('Parent type') - ) - parent = DynamicModelChoiceField( +class ServiceForm(ServicePortMappingsMixin, GenericObjectFormMixin, PrimaryModelForm): + parent = GenericObjectChoiceField( label=_('Parent'), - queryset=Device.objects.none(), # Initial queryset + content_type_queryset=ContentType.objects.filter(SERVICE_ASSIGNMENT_MODELS), required=True, - disabled=True, - selector=True - ) - ports = NumericArrayField( - label=_('Ports'), - base_field=forms.IntegerField( - min_value=SERVICE_PORT_MIN, - max_value=SERVICE_PORT_MAX - ), - help_text=_("Comma-separated list of one or more port numbers. A range may be specified using a hyphen.") + selector=True, + hx_target_id='service', ) ipaddresses = DynamicModelMultipleChoiceField( queryset=IPAddress.objects.all(), @@ -800,58 +848,30 @@ class ServiceForm(PrimaryModelForm): fieldsets = ( FieldSet( - 'parent_object_type', 'parent', 'name', - InlineFields('protocol', 'ports', label=_('Port(s)')), - 'ipaddresses', 'description', 'tags', name=_('Application Service') + 'parent', 'name', 'port_mappings', + 'ipaddresses', 'description', 'tags', name=_('Application Service'), + html_id='service', ), ) class Meta: model = Service fields = [ - 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'owner', 'comments', 'tags', - 'parent_object_type', + 'name', 'port_mappings', 'ipaddresses', 'description', 'owner', 'comments', 'tags', ] def __init__(self, *args, **kwargs): - initial = kwargs.get('initial', {}).copy() - - if (instance := kwargs.get('instance', None)) and instance.parent: - initial['parent'] = instance.parent - - kwargs['initial'] = initial - super().__init__(*args, **kwargs) - if parent_object_type_id := get_field_value(self, 'parent_object_type'): - try: - parent_type = ContentType.objects.get(pk=parent_object_type_id) - model = parent_type.model_class() - if model == Device: - self.fields['ipaddresses'].widget.add_query_params({ - 'device_id': '$parent', - }) - elif model == VirtualMachine: - self.fields['ipaddresses'].widget.add_query_params({ - 'virtual_machine_id': '$parent', - }) - elif model == FHRPGroup: - self.fields['ipaddresses'].widget.add_query_params({ - 'fhrpgroup_id': '$parent', - }) - self.fields['parent'].queryset = model.objects.all() - self.fields['parent'].widget.attrs['selector'] = model._meta.label_lower - self.fields['parent'].disabled = False - self.fields['parent'].label = _(bettertitle(model._meta.verbose_name)) - except ObjectDoesNotExist: - pass - - if self.instance and self.instance.pk and parent_object_type_id != self.instance.parent_object_type_id: - self.initial['parent'] = None - - def clean(self): - super().clean() - self.instance.parent = self.cleaned_data.get('parent') + # Filter the IP address selector to those belonging to the selected parent. The object subwidget is + # named "parent_object_id", so the dynamic param references "$parent_object_id". + parent_model = self.fields['parent'].selected_model + if parent_model is Device: + self.fields['ipaddresses'].widget.add_query_params({'device_id': '$parent_object_id'}) + elif parent_model is VirtualMachine: + self.fields['ipaddresses'].widget.add_query_params({'virtual_machine_id': '$parent_object_id'}) + elif parent_model is FHRPGroup: + self.fields['ipaddresses'].widget.add_query_params({'fhrpgroup_id': '$parent_object_id'}) class ServiceCreateForm(ServiceForm): @@ -863,26 +883,27 @@ class ServiceCreateForm(ServiceForm): fieldsets = ( FieldSet( - 'parent_object_type', 'parent', + 'parent', TabbedGroups( FieldSet('service_template', name=_('From Template')), - FieldSet('name', 'protocol', 'ports', name=_('Custom')), + FieldSet('name', 'port_mappings', name=_('Custom')), ), - 'ipaddresses', 'description', 'tags', name=_('Application Service') + 'ipaddresses', 'description', 'tags', name=_('Application Service'), + html_id='service', ), ) class Meta(ServiceForm.Meta): fields = [ - 'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'owner', - 'comments', 'tags', 'parent_object_type', + 'service_template', 'name', 'port_mappings', 'ipaddresses', 'description', 'owner', + 'comments', 'tags', ] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Fields which may be populated from a ServiceTemplate are not required - for field in ('name', 'protocol', 'ports'): + for field in ('name', 'port_mappings'): self.fields[field].required = False self.fields[field].widget.is_required = False @@ -892,11 +913,10 @@ class ServiceCreateForm(ServiceForm): # Create a new Service from the specified template service_template = self.cleaned_data['service_template'] self.cleaned_data['name'] = service_template.name - self.cleaned_data['protocol'] = service_template.protocol - self.cleaned_data['ports'] = service_template.ports + self.cleaned_data['port_mappings'] = list(service_template.port_mappings) if not self.cleaned_data['description']: self.cleaned_data['description'] = service_template.description - elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')): + elif not self.cleaned_data.get('name') or not self.cleaned_data.get('port_mappings'): raise forms.ValidationError( - _("Must specify name, protocol, and port(s) if not using an application service template.") + _("Must specify name and port mapping(s) if not using an application service template.") ) diff --git a/netbox/ipam/forms/widgets.py b/netbox/ipam/forms/widgets.py new file mode 100644 index 000000000..3054bb9b9 --- /dev/null +++ b/netbox/ipam/forms/widgets.py @@ -0,0 +1,63 @@ +import json + +from django import forms +from django.forms.utils import flatatt + +from ipam.choices import ServiceProtocolChoices + +__all__ = ( + 'PortMappingWidget', +) + + +class PortMappingWidget(forms.Widget): + """ + Renders a dynamic set of (protocol, ports) rows. The rows are serialized to a JSON string held in a + single hidden input (client-side JS keeps the hidden input in sync as rows are added/removed). Each + row's ``ports`` value is a raw comma/range string (e.g. "80,443,8000-8010"); the server expands it. + """ + template_name = 'ipam/widgets/port_mappings.html' + + # aria-* attributes which render_field_with_aria() sets per-field, and which must be copied onto the + # row controls: the wrapping
    isn't a form control, so assistive technology ignores them there. + CONTROL_ATTRS = ('aria-describedby', 'aria-invalid') + + def id_for_label(self, id_): + # The field's