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 2bd6e5433..2cb06eaa2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,7 +44,7 @@ jobs: cache: pip - name: Install build tooling - run: python -m pip install --upgrade build twine + run: python -m pip install --upgrade build twine packaging - name: Install documentation toolchain run: python -m pip install -r requirements.txt @@ -60,6 +60,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: @@ -276,15 +290,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 @@ -293,36 +306,45 @@ 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 uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 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 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + with: + print-hash: true diff --git a/base_requirements.txt b/base_requirements.txt index 96f7f5a82..0e65e3964 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -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 diff --git a/contrib/openapi.json b/contrib/openapi.json index 7a6989337..09a25acd3 100644 --- a/contrib/openapi.json +++ b/contrib/openapi.json @@ -22441,6 +22441,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", @@ -44654,6 +44681,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", @@ -51314,7 +51426,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedDeviceWithConfigContextList" + "$ref": "#/components/schemas/PaginatedDeviceList" } } }, @@ -51334,12 +51446,12 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/WritableDeviceWithConfigContextRequest" + "$ref": "#/components/schemas/WritableDeviceRequest" }, { "type": "array", "items": { - "$ref": "#/components/schemas/WritableDeviceWithConfigContextRequest" + "$ref": "#/components/schemas/WritableDeviceRequest" } } ] @@ -51349,12 +51461,12 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/WritableDeviceWithConfigContextRequest" + "$ref": "#/components/schemas/WritableDeviceRequest" }, { "type": "array", "items": { - "$ref": "#/components/schemas/WritableDeviceWithConfigContextRequest" + "$ref": "#/components/schemas/WritableDeviceRequest" } } ] @@ -51376,7 +51488,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceWithConfigContext" + "$ref": "#/components/schemas/Device" } } }, @@ -51396,7 +51508,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/BulkDeviceWithConfigContextRequest" + "$ref": "#/components/schemas/BulkDeviceRequest" } } }, @@ -51404,7 +51516,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/BulkDeviceWithConfigContextRequest" + "$ref": "#/components/schemas/BulkDeviceRequest" } } } @@ -51426,7 +51538,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/DeviceWithConfigContext" + "$ref": "#/components/schemas/Device" } } } @@ -51447,7 +51559,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/PatchedBulkDeviceWithConfigContextRequest" + "$ref": "#/components/schemas/PatchedBulkDeviceRequest" } } }, @@ -51455,7 +51567,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/PatchedBulkDeviceWithConfigContextRequest" + "$ref": "#/components/schemas/PatchedBulkDeviceRequest" } } } @@ -51477,7 +51589,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/DeviceWithConfigContext" + "$ref": "#/components/schemas/Device" } } } @@ -51498,7 +51610,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/DeviceWithConfigContextRequest" + "$ref": "#/components/schemas/DeviceRequest" } } }, @@ -51506,7 +51618,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/DeviceWithConfigContextRequest" + "$ref": "#/components/schemas/DeviceRequest" } } } @@ -51583,7 +51695,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceWithConfigContext" + "$ref": "#/components/schemas/Device" } } }, @@ -51612,12 +51724,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" } } }, @@ -51636,7 +51748,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceWithConfigContext" + "$ref": "#/components/schemas/Device" } } }, @@ -51665,12 +51777,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" } } } @@ -51688,7 +51800,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceWithConfigContext" + "$ref": "#/components/schemas/Device" } } }, @@ -56509,6 +56621,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", @@ -57358,6 +57640,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", @@ -57919,7 +58225,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b067eb1f050c6ae9" + "x-spec-enum-id": "ab7c1626812ec9d4" } }, "explode": true, @@ -57939,7 +58245,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b067eb1f050c6ae9" + "x-spec-enum-id": "ab7c1626812ec9d4" } }, "explode": true, @@ -57952,7 +58258,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b067eb1f050c6ae9" + "x-spec-enum-id": "ab7c1626812ec9d4" } }, "explode": true, @@ -57965,7 +58271,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b067eb1f050c6ae9" + "x-spec-enum-id": "ab7c1626812ec9d4" } }, "explode": true, @@ -57978,7 +58284,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b067eb1f050c6ae9" + "x-spec-enum-id": "ab7c1626812ec9d4" } }, "explode": true, @@ -57991,7 +58297,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b067eb1f050c6ae9" + "x-spec-enum-id": "ab7c1626812ec9d4" } }, "explode": true, @@ -58004,7 +58310,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b067eb1f050c6ae9" + "x-spec-enum-id": "ab7c1626812ec9d4" } }, "explode": true, @@ -58017,7 +58323,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b067eb1f050c6ae9" + "x-spec-enum-id": "ab7c1626812ec9d4" } }, "explode": true, @@ -58030,7 +58336,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b067eb1f050c6ae9" + "x-spec-enum-id": "ab7c1626812ec9d4" } }, "explode": true, @@ -58043,7 +58349,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b067eb1f050c6ae9" + "x-spec-enum-id": "ab7c1626812ec9d4" } }, "explode": true, @@ -58056,7 +58362,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b067eb1f050c6ae9" + "x-spec-enum-id": "ab7c1626812ec9d4" } }, "explode": true, @@ -58069,7 +58375,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b067eb1f050c6ae9" + "x-spec-enum-id": "ab7c1626812ec9d4" } }, "explode": true, @@ -58830,6 +59136,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", @@ -62333,7 +62809,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b067eb1f050c6ae9" + "x-spec-enum-id": "ab7c1626812ec9d4" } }, "explode": true, @@ -62353,7 +62829,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b067eb1f050c6ae9" + "x-spec-enum-id": "ab7c1626812ec9d4" } }, "explode": true, @@ -62366,7 +62842,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b067eb1f050c6ae9" + "x-spec-enum-id": "ab7c1626812ec9d4" } }, "explode": true, @@ -62379,7 +62855,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b067eb1f050c6ae9" + "x-spec-enum-id": "ab7c1626812ec9d4" } }, "explode": true, @@ -62392,7 +62868,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b067eb1f050c6ae9" + "x-spec-enum-id": "ab7c1626812ec9d4" } }, "explode": true, @@ -62405,7 +62881,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b067eb1f050c6ae9" + "x-spec-enum-id": "ab7c1626812ec9d4" } }, "explode": true, @@ -62418,7 +62894,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b067eb1f050c6ae9" + "x-spec-enum-id": "ab7c1626812ec9d4" } }, "explode": true, @@ -62431,7 +62907,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b067eb1f050c6ae9" + "x-spec-enum-id": "ab7c1626812ec9d4" } }, "explode": true, @@ -62444,7 +62920,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b067eb1f050c6ae9" + "x-spec-enum-id": "ab7c1626812ec9d4" } }, "explode": true, @@ -62457,7 +62933,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b067eb1f050c6ae9" + "x-spec-enum-id": "ab7c1626812ec9d4" } }, "explode": true, @@ -62470,7 +62946,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b067eb1f050c6ae9" + "x-spec-enum-id": "ab7c1626812ec9d4" } }, "explode": true, @@ -62483,7 +62959,7 @@ "type": "array", "items": { "type": "string", - "x-spec-enum-id": "b067eb1f050c6ae9" + "x-spec-enum-id": "ab7c1626812ec9d4" } }, "explode": true, @@ -75649,6 +76125,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", @@ -76461,6 +76989,1715 @@ } } }, + "/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": "" + } + } + }, + "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": "" + } + } + }, + "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": "" + } + } + }, + "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" + } + } + } + }, + "/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", @@ -77482,6 +79719,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", @@ -80693,6 +82982,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", @@ -81360,6 +83734,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", @@ -135300,6 +137726,13 @@ "explode": true, "style": "form" }, + { + "in": "query", + "name": "nulls_first", + "schema": { + "type": "boolean" + } + }, { "in": "query", "name": "object_type", @@ -135764,7 +138197,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", @@ -135785,7 +138218,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", @@ -135799,7 +138232,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", @@ -135813,7 +138246,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", @@ -135827,7 +138260,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", @@ -135841,7 +138274,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", @@ -135855,7 +138288,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", @@ -135869,7 +138302,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", @@ -135883,7 +138316,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", @@ -135897,7 +138330,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", @@ -135911,7 +138344,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", @@ -135925,7 +138358,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", @@ -154389,6 +156822,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", @@ -215201,7 +217719,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedVirtualMachineWithConfigContextList" + "$ref": "#/components/schemas/PaginatedVirtualMachineList" } } }, @@ -215221,12 +217739,12 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/WritableVirtualMachineWithConfigContextRequest" + "$ref": "#/components/schemas/WritableVirtualMachineRequest" }, { "type": "array", "items": { - "$ref": "#/components/schemas/WritableVirtualMachineWithConfigContextRequest" + "$ref": "#/components/schemas/WritableVirtualMachineRequest" } } ] @@ -215236,12 +217754,12 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/WritableVirtualMachineWithConfigContextRequest" + "$ref": "#/components/schemas/WritableVirtualMachineRequest" }, { "type": "array", "items": { - "$ref": "#/components/schemas/WritableVirtualMachineWithConfigContextRequest" + "$ref": "#/components/schemas/WritableVirtualMachineRequest" } } ] @@ -215263,7 +217781,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VirtualMachineWithConfigContext" + "$ref": "#/components/schemas/VirtualMachine" } } }, @@ -215283,7 +217801,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/BulkVirtualMachineWithConfigContextRequest" + "$ref": "#/components/schemas/BulkVirtualMachineRequest" } } }, @@ -215291,7 +217809,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/BulkVirtualMachineWithConfigContextRequest" + "$ref": "#/components/schemas/BulkVirtualMachineRequest" } } } @@ -215313,7 +217831,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/VirtualMachineWithConfigContext" + "$ref": "#/components/schemas/VirtualMachine" } } } @@ -215334,7 +217852,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/PatchedBulkVirtualMachineWithConfigContextRequest" + "$ref": "#/components/schemas/PatchedBulkVirtualMachineRequest" } } }, @@ -215342,7 +217860,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/PatchedBulkVirtualMachineWithConfigContextRequest" + "$ref": "#/components/schemas/PatchedBulkVirtualMachineRequest" } } } @@ -215364,7 +217882,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/VirtualMachineWithConfigContext" + "$ref": "#/components/schemas/VirtualMachine" } } } @@ -215385,7 +217903,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/VirtualMachineWithConfigContextRequest" + "$ref": "#/components/schemas/VirtualMachineRequest" } } }, @@ -215393,7 +217911,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/VirtualMachineWithConfigContextRequest" + "$ref": "#/components/schemas/VirtualMachineRequest" } } } @@ -215470,7 +217988,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VirtualMachineWithConfigContext" + "$ref": "#/components/schemas/VirtualMachine" } } }, @@ -215499,12 +218017,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" } } }, @@ -215523,7 +218041,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VirtualMachineWithConfigContext" + "$ref": "#/components/schemas/VirtualMachine" } } }, @@ -215552,12 +218070,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" } } } @@ -215575,7 +218093,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VirtualMachineWithConfigContext" + "$ref": "#/components/schemas/VirtualMachine" } } }, @@ -241690,7 +244208,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 write-only `mac_address` shortcut\nfield for creating/updating the primary MACAddress in a single request.", "properties": { "id": { "type": "integer", @@ -241743,7 +244261,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 write-only `mac_address` shortcut\nfield for creating/updating the primary MACAddress in a single request.", "properties": { "device": { "oneOf": [ @@ -242320,6 +244838,120 @@ "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" + ] + }, + "BriefModuleBayTypeRequest": { + "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 + } + }, + "required": [ + "name", + "slug" + ] + }, "BriefModuleRequest": { "type": "object", "description": "Base serializer class for models inheriting from PrimaryModel.", @@ -247038,8 +249670,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", @@ -247115,6 +249747,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\")." @@ -247511,271 +250147,7 @@ "name" ] }, - "BulkDeviceRoleRequest": { - "type": "object", - "description": "Base serializer class for models inheriting from NestedGroupModel.", - "properties": { - "id": { - "type": "integer" - }, - "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": { - "allOf": [ - { - "$ref": "#/components/schemas/NestedDeviceRoleRequest" - } - ], - "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": [ - "id", - "name", - "slug" - ] - }, - "BulkDeviceTypeRequest": { - "type": "object", - "description": "Base serializer class for models inheriting from PrimaryModel.", - "properties": { - "id": { - "type": "integer" - }, - "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", - "description": "* `parent` - Parent\n* `child` - Child", - "x-spec-enum-id": "65a61d5e1deb4a24", - "nullable": true - }, - "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": [ - "id", - "manufacturer", - "model", - "slug" - ] - }, - "BulkDeviceWithConfigContextRequest": { + "BulkDeviceRequest": { "type": "object", "description": "Base serializer class for models inheriting from PrimaryModel.", "properties": { @@ -248114,6 +250486,276 @@ "site" ] }, + "BulkDeviceRoleRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from NestedGroupModel.", + "properties": { + "id": { + "type": "integer" + }, + "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": { + "allOf": [ + { + "$ref": "#/components/schemas/NestedDeviceRoleRequest" + } + ], + "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": [ + "id", + "name", + "slug" + ] + }, + "BulkDeviceTypeRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "id": { + "type": "integer" + }, + "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", + "description": "* `parent` - Parent\n* `child` - Child", + "x-spec-enum-id": "65a61d5e1deb4a24", + "nullable": true + }, + "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 + }, + "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": [ + "id", + "manufacturer", + "model", + "slug" + ] + }, "BulkEventRuleRequest": { "type": "object", "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", @@ -249569,7 +252211,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 write-only `mac_address` shortcut\nfield for creating/updating the primary MACAddress in a single request.", "properties": { "id": { "type": "integer" @@ -249621,6 +252263,7 @@ "virtual", "bridge", "lag", + "channel", "100base-fx", "100base-lfx", "100base-tx", @@ -249836,8 +252479,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-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 (250 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* `other` - Other", - "x-spec-enum-id": "b067eb1f050c6ae9" + "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-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 (250 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* `other` - Other", + "x-spec-enum-id": "ab7c1626812ec9d4" + }, + "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" @@ -249872,6 +252529,11 @@ "minimum": 1, "nullable": true }, + "mac_address": { + "type": "string", + "nullable": true, + "minLength": 1 + }, "primary_mac_address": { "oneOf": [ { @@ -250373,6 +253035,7 @@ "virtual", "bridge", "lag", + "channel", "100base-fx", "100base-lfx", "100base-tx", @@ -250588,8 +253251,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-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 (250 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* `other` - Other", - "x-spec-enum-id": "b067eb1f050c6ae9" + "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-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 (250 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* `other` - Other", + "x-spec-enum-id": "ab7c1626812ec9d4" + }, + "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" @@ -250602,6 +253279,14 @@ "type": "string", "maxLength": 200 }, + "parent": { + "allOf": [ + { + "$ref": "#/components/schemas/NestedInterfaceTemplateRequest" + } + ], + "nullable": true + }, "bridge": { "allOf": [ { @@ -251463,6 +254148,12 @@ "type": "string", "maxLength": 200 }, + "module_bay_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BriefModuleBayTypeRequest" + } + }, "installed_module": { "oneOf": [ { @@ -251573,6 +254264,12 @@ "description": { "type": "string", "maxLength": 200 + }, + "module_bay_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BriefModuleBayTypeRequest" + } } }, "required": [ @@ -251580,6 +254277,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.", @@ -251823,6 +254607,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 @@ -251830,6 +254620,12 @@ "attributes": { "nullable": true }, + "module_bay_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BriefModuleBayTypeRequest" + } + }, "owner": { "oneOf": [ { @@ -255720,10 +258516,6 @@ "minimum": 0, "nullable": true, "description": "ID of the cryptographic pepper used to hash the token (v2 only)" - }, - "token": { - "type": "string", - "minLength": 1 } }, "required": [ @@ -256387,7 +259179,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 write-only `mac_address` shortcut\nfield for creating/updating the primary MACAddress in a single request.", "properties": { "id": { "type": "integer" @@ -256432,6 +259224,11 @@ "minimum": 1, "nullable": true }, + "mac_address": { + "type": "string", + "nullable": true, + "minLength": 1 + }, "primary_mac_address": { "oneOf": [ { @@ -257162,95 +259959,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": { @@ -257514,6 +260223,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.", @@ -257561,7 +260358,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", @@ -257578,6 +260375,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 (WEBHOOK_DEFAULT_TIMEOUT)." + }, "custom_fields": { "type": "object", "additionalProperties": {} @@ -263047,8 +265851,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", @@ -263057,7 +265861,7 @@ "Text (long)", "Integer", "Decimal", - "Boolean (true/false)", + "Boolean", "Date", "Date & time", "URL", @@ -263186,6 +265990,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\")." @@ -263511,8 +266319,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", @@ -263588,6 +266396,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\")." @@ -264497,6 +267309,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" @@ -264565,6 +267381,7 @@ } }, "required": [ + "config_context", "console_port_count", "console_server_port_count", "created", @@ -264862,6 +267679,341 @@ "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" + }, + "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.", @@ -265238,6 +268390,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", @@ -265471,6 +268629,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", @@ -265521,727 +268685,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.", @@ -270043,7 +272486,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 write-only `mac_address` shortcut\nfield for creating/updating the primary MACAddress in a single request.", "properties": { "id": { "type": "integer", @@ -270097,6 +272540,7 @@ "virtual", "bridge", "lag", + "channel", "100base-fx", "100base-lfx", "100base-tx", @@ -270312,8 +272756,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-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 (250 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* `other` - Other", - "x-spec-enum-id": "b067eb1f050c6ae9" + "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-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 (250 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* `other` - Other", + "x-spec-enum-id": "ab7c1626812ec9d4" }, "label": { "type": "string", @@ -270321,6 +272765,7 @@ "Virtual", "Bridge", "Link Aggregation Group (LAG)", + "Channel", "100BASE-FX (10/100ME)", "100BASE-LFX (10/100ME)", "100BASE-TX (10/100ME)", @@ -270538,6 +272983,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" }, @@ -270580,7 +273039,6 @@ }, "mac_address": { "type": "string", - "readOnly": true, "nullable": true }, "primary_mac_address": { @@ -271367,7 +273825,6 @@ "last_updated", "link_peers", "link_peers_type", - "mac_address", "mac_addresses", "name", "type", @@ -271377,7 +273834,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 write-only `mac_address` shortcut\nfield for creating/updating the primary MACAddress in a single request.", "properties": { "device": { "oneOf": [ @@ -271426,6 +273883,7 @@ "virtual", "bridge", "lag", + "channel", "100base-fx", "100base-lfx", "100base-tx", @@ -271641,8 +274099,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-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 (250 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* `other` - Other", - "x-spec-enum-id": "b067eb1f050c6ae9" + "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-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 (250 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* `other` - Other", + "x-spec-enum-id": "ab7c1626812ec9d4" + }, + "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" @@ -271677,6 +274149,11 @@ "minimum": 1, "nullable": true }, + "mac_address": { + "type": "string", + "nullable": true, + "minLength": 1 + }, "primary_mac_address": { "oneOf": [ { @@ -272173,6 +274650,7 @@ "virtual", "bridge", "lag", + "channel", "100base-fx", "100base-lfx", "100base-tx", @@ -272388,8 +274866,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-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 (250 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* `other` - Other", - "x-spec-enum-id": "b067eb1f050c6ae9" + "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-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 (250 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* `other` - Other", + "x-spec-enum-id": "ab7c1626812ec9d4" }, "label": { "type": "string", @@ -272397,6 +274875,7 @@ "Virtual", "Bridge", "Link Aggregation Group (LAG)", + "Channel", "100BASE-FX (10/100ME)", "100BASE-LFX (10/100ME)", "100BASE-TX (10/100ME)", @@ -272614,6 +275093,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" }, @@ -272625,6 +275118,14 @@ "type": "string", "maxLength": 200 }, + "parent": { + "allOf": [ + { + "$ref": "#/components/schemas/NestedInterfaceTemplate" + } + ], + "nullable": true + }, "bridge": { "allOf": [ { @@ -272792,6 +275293,7 @@ "virtual", "bridge", "lag", + "channel", "100base-fx", "100base-lfx", "100base-tx", @@ -273007,8 +275509,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-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 (250 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* `other` - Other", - "x-spec-enum-id": "b067eb1f050c6ae9" + "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-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 (250 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* `other` - Other", + "x-spec-enum-id": "ab7c1626812ec9d4" + }, + "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" @@ -273021,6 +275537,14 @@ "type": "string", "maxLength": 200 }, + "parent": { + "allOf": [ + { + "$ref": "#/components/schemas/NestedInterfaceTemplateRequest" + } + ], + "nullable": true + }, "bridge": { "allOf": [ { @@ -273827,6 +276351,11 @@ "format": "date-time", "nullable": true }, + "execution_time": { + "type": "string", + "readOnly": true, + "nullable": true + }, "user": { "allOf": [ { @@ -273885,6 +276414,7 @@ "display", "display_url", "error", + "execution_time", "id", "job_id", "name", @@ -274759,6 +277289,10 @@ "readOnly": true, "nullable": true }, + "is_primary": { + "type": "boolean", + "readOnly": true + }, "description": { "type": "string", "maxLength": 200 @@ -274803,6 +277337,7 @@ "display", "display_url", "id", + "is_primary", "last_updated", "mac_address", "url" @@ -275130,6 +277665,10 @@ "format": "date-time", "readOnly": true, "nullable": true + }, + "is_bay_compatible": { + "type": "boolean", + "readOnly": true } }, "required": [ @@ -275138,6 +277677,7 @@ "display", "display_url", "id", + "is_bay_compatible", "last_updated", "module_bay", "module_type", @@ -275198,6 +277738,12 @@ "type": "string", "maxLength": 200 }, + "module_bay_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BriefModuleBayType" + } + }, "installed_module": { "allOf": [ { @@ -275240,6 +277786,10 @@ "type": "boolean", "readOnly": true, "title": " occupied" + }, + "is_module_compatible": { + "type": "boolean", + "readOnly": true } }, "required": [ @@ -275249,6 +277799,7 @@ "display", "display_url", "id", + "is_module_compatible", "last_updated", "name", "url" @@ -275306,6 +277857,12 @@ "type": "string", "maxLength": 200 }, + "module_bay_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BriefModuleBayTypeRequest" + } + }, "installed_module": { "oneOf": [ { @@ -275409,6 +277966,12 @@ "type": "string", "maxLength": 200 }, + "module_bay_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BriefModuleBayType" + } + }, "created": { "type": "string", "format": "date-time", @@ -275489,12 +278052,202 @@ "description": { "type": "string", "maxLength": 200 + }, + "module_bay_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BriefModuleBayTypeRequest" + } } }, "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.", @@ -275709,6 +278462,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 @@ -275716,6 +278475,12 @@ "attributes": { "nullable": true }, + "module_bay_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BriefModuleBayType" + } + }, "owner": { "allOf": [ { @@ -276012,6 +278777,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 @@ -276019,6 +278790,12 @@ "attributes": { "nullable": true }, + "module_bay_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BriefModuleBayTypeRequest" + } + }, "owner": { "oneOf": [ { @@ -278946,6 +281723,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": [ @@ -279008,37 +281816,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": [ @@ -279938,6 +282715,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": [ @@ -281860,6 +284668,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": [ @@ -281891,37 +284730,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": [ @@ -284608,8 +287416,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", @@ -284685,6 +287493,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\")." @@ -285067,266 +287879,7 @@ "id" ] }, - "PatchedBulkDeviceRoleRequest": { - "type": "object", - "description": "Base serializer class for models inheriting from NestedGroupModel.", - "properties": { - "id": { - "type": "integer" - }, - "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": { - "allOf": [ - { - "$ref": "#/components/schemas/NestedDeviceRoleRequest" - } - ], - "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": [ - "id" - ] - }, - "PatchedBulkDeviceTypeRequest": { - "type": "object", - "description": "Base serializer class for models inheriting from PrimaryModel.", - "properties": { - "id": { - "type": "integer" - }, - "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", - "description": "* `parent` - Parent\n* `child` - Child", - "x-spec-enum-id": "65a61d5e1deb4a24", - "nullable": true - }, - "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": [ - "id" - ] - }, - "PatchedBulkDeviceWithConfigContextRequest": { + "PatchedBulkDeviceRequest": { "type": "object", "description": "Base serializer class for models inheriting from PrimaryModel.", "properties": { @@ -285662,6 +288215,271 @@ "id" ] }, + "PatchedBulkDeviceRoleRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from NestedGroupModel.", + "properties": { + "id": { + "type": "integer" + }, + "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": { + "allOf": [ + { + "$ref": "#/components/schemas/NestedDeviceRoleRequest" + } + ], + "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": [ + "id" + ] + }, + "PatchedBulkDeviceTypeRequest": { + "type": "object", + "description": "Base serializer class for models inheriting from PrimaryModel.", + "properties": { + "id": { + "type": "integer" + }, + "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", + "description": "* `parent` - Parent\n* `child` - Child", + "x-spec-enum-id": "65a61d5e1deb4a24", + "nullable": true + }, + "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 + }, + "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": [ + "id" + ] + }, "PatchedBulkEventRuleRequest": { "type": "object", "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.", @@ -287079,7 +289897,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 write-only `mac_address` shortcut\nfield for creating/updating the primary MACAddress in a single request.", "properties": { "id": { "type": "integer" @@ -287131,6 +289949,7 @@ "virtual", "bridge", "lag", + "channel", "100base-fx", "100base-lfx", "100base-tx", @@ -287346,8 +290165,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-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 (250 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* `other` - Other", - "x-spec-enum-id": "b067eb1f050c6ae9" + "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-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 (250 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* `other` - Other", + "x-spec-enum-id": "ab7c1626812ec9d4" + }, + "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" @@ -287382,6 +290215,11 @@ "minimum": 1, "nullable": true }, + "mac_address": { + "type": "string", + "nullable": true, + "minLength": 1 + }, "primary_mac_address": { "oneOf": [ { @@ -287880,6 +290718,7 @@ "virtual", "bridge", "lag", + "channel", "100base-fx", "100base-lfx", "100base-tx", @@ -288095,8 +290934,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-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 (250 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* `other` - Other", - "x-spec-enum-id": "b067eb1f050c6ae9" + "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-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 (250 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* `other` - Other", + "x-spec-enum-id": "ab7c1626812ec9d4" + }, + "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" @@ -288109,6 +290962,14 @@ "type": "string", "maxLength": 200 }, + "parent": { + "allOf": [ + { + "$ref": "#/components/schemas/NestedInterfaceTemplateRequest" + } + ], + "nullable": true + }, "bridge": { "allOf": [ { @@ -288948,6 +291809,12 @@ "type": "string", "maxLength": 200 }, + "module_bay_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BriefModuleBayTypeRequest" + } + }, "installed_module": { "oneOf": [ { @@ -289056,6 +291923,97 @@ "description": { "type": "string", "maxLength": 200 + }, + "module_bay_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BriefModuleBayTypeRequest" + } + } + }, + "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": [ @@ -289301,6 +292259,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 @@ -289308,6 +292272,12 @@ "attributes": { "nullable": true }, + "module_bay_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BriefModuleBayTypeRequest" + } + }, "owner": { "oneOf": [ { @@ -293114,10 +296084,6 @@ "minimum": 0, "nullable": true, "description": "ID of the cryptographic pepper used to hash the token (v2 only)" - }, - "token": { - "type": "string", - "minLength": 1 } }, "required": [ @@ -293762,7 +296728,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 write-only `mac_address` shortcut\nfield for creating/updating the primary MACAddress in a single request.", "properties": { "id": { "type": "integer" @@ -293807,6 +296773,11 @@ "minimum": 1, "nullable": true }, + "mac_address": { + "type": "string", + "nullable": true, + "minLength": 1 + }, "primary_mac_address": { "oneOf": [ { @@ -294520,93 +297491,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": { @@ -294869,6 +297754,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.", @@ -294916,7 +297887,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", @@ -294933,6 +297904,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 (WEBHOOK_DEFAULT_TIMEOUT)." + }, "custom_fields": { "type": "object", "additionalProperties": {} @@ -296856,6 +299834,12 @@ "type": "string", "maxLength": 200 }, + "module_bay_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BriefModuleBayTypeRequest" + } + }, "installed_module": { "oneOf": [ { @@ -296958,6 +299942,91 @@ "description": { "type": "string", "maxLength": 200 + }, + "module_bay_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BriefModuleBayTypeRequest" + } + } + } + }, + "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": {} } } }, @@ -298051,10 +301120,6 @@ "minimum": 0, "nullable": true, "description": "ID of the cryptographic pepper used to hash the token (v2 only)" - }, - "token": { - "type": "string", - "minLength": 1 } } }, @@ -298643,7 +301708,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", @@ -298660,6 +301725,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 (WEBHOOK_DEFAULT_TIMEOUT)." + }, "custom_fields": { "type": "object", "additionalProperties": {} @@ -299907,8 +302979,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", @@ -299984,6 +303056,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\")." @@ -300143,251 +303219,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": { @@ -300722,6 +303554,256 @@ } } }, + "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 + }, + "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.", @@ -301837,7 +304919,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 write-only `mac_address` shortcut\nfield for creating/updating the primary MACAddress in a single request.", "properties": { "device": { "oneOf": [ @@ -301886,6 +304968,7 @@ "virtual", "bridge", "lag", + "channel", "100base-fx", "100base-lfx", "100base-tx", @@ -302101,8 +305184,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-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 (250 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* `other` - Other", - "x-spec-enum-id": "b067eb1f050c6ae9" + "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-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 (250 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* `other` - Other", + "x-spec-enum-id": "ab7c1626812ec9d4" + }, + "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" @@ -302128,6 +305225,11 @@ "minimum": 1, "nullable": true }, + "mac_address": { + "type": "string", + "nullable": true, + "minLength": 1 + }, "primary_mac_address": { "oneOf": [ { @@ -302632,6 +305734,7 @@ "virtual", "bridge", "lag", + "channel", "100base-fx", "100base-lfx", "100base-tx", @@ -302847,8 +305950,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-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 (250 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* `other` - Other", - "x-spec-enum-id": "b067eb1f050c6ae9" + "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-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 (250 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* `other` - Other", + "x-spec-enum-id": "ab7c1626812ec9d4" + }, + "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" @@ -302861,6 +305978,11 @@ "type": "string", "maxLength": 200 }, + "parent": { + "type": "integer", + "nullable": true, + "title": "Parent interface" + }, "bridge": { "type": "integer", "nullable": true, @@ -303481,6 +306603,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 @@ -303488,6 +306616,12 @@ "attributes": { "nullable": true }, + "module_bay_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BriefModuleBayTypeRequest" + } + }, "owner": { "oneOf": [ { @@ -306380,7 +309514,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 write-only `mac_address` shortcut\nfield for creating/updating the primary MACAddress in a single request.", "properties": { "virtual_machine": { "oneOf": [ @@ -306416,6 +309550,11 @@ "minimum": 1, "nullable": true }, + "mac_address": { + "type": "string", + "nullable": true, + "minLength": 1 + }, "primary_mac_address": { "oneOf": [ { @@ -306879,7 +310018,7 @@ } } }, - "PatchedWritableVirtualMachineWithConfigContextRequest": { + "PatchedWritableVirtualMachineRequest": { "type": "object", "description": "Base serializer class for models inheriting from PrimaryModel.", "properties": { @@ -316787,7 +319926,8 @@ "description": "ID of the cryptographic pepper used to hash the token (v2 only)" }, "token": { - "type": "string" + "type": "string", + "readOnly": true } }, "required": [ @@ -316796,6 +319936,7 @@ "display_url", "id", "key", + "token", "url", "user" ] @@ -316873,7 +320014,8 @@ "maxLength": 200 }, "token": { - "type": "string" + "type": "string", + "readOnly": true } }, "required": [ @@ -316883,6 +320025,7 @@ "id", "key", "last_used", + "token", "url", "user" ] @@ -316928,10 +320071,6 @@ "type": "string", "writeOnly": true, "minLength": 1 - }, - "token": { - "type": "string", - "minLength": 1 } }, "required": [ @@ -316992,10 +320131,6 @@ "minimum": 0, "nullable": true, "description": "ID of the cryptographic pepper used to hash the token (v2 only)" - }, - "token": { - "type": "string", - "minLength": 1 } }, "required": [ @@ -318479,7 +321614,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 write-only `mac_address` shortcut\nfield for creating/updating the primary MACAddress in a single request.", "properties": { "id": { "type": "integer", @@ -318533,7 +321668,6 @@ }, "mac_address": { "type": "string", - "readOnly": true, "nullable": true }, "primary_mac_address": { @@ -318677,7 +321811,6 @@ "id", "l2vpn_termination", "last_updated", - "mac_address", "mac_addresses", "name", "url", @@ -318686,7 +321819,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 write-only `mac_address` shortcut\nfield for creating/updating the primary MACAddress in a single request.", "properties": { "virtual_machine": { "oneOf": [ @@ -318728,6 +321861,11 @@ "minimum": 1, "nullable": true }, + "mac_address": { + "type": "string", + "nullable": true, + "minLength": 1 + }, "primary_mac_address": { "oneOf": [ { @@ -320192,198 +323330,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": { @@ -320645,7 +323592,7 @@ "virtual_disk_count" ] }, - "VirtualMachineWithConfigContextRequest": { + "VirtualMachineRequest": { "type": "object", "description": "Base serializer class for models inheriting from PrimaryModel.", "properties": { @@ -320905,6 +323852,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.", @@ -320964,7 +324102,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", @@ -320981,6 +324119,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 (WEBHOOK_DEFAULT_TIMEOUT)." + }, "custom_fields": { "type": "object", "additionalProperties": {} @@ -321067,7 +324212,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", @@ -321084,6 +324229,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 (WEBHOOK_DEFAULT_TIMEOUT)." + }, "custom_fields": { "type": "object", "additionalProperties": {} @@ -323225,8 +326377,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", @@ -323302,6 +326454,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\")." @@ -323470,260 +326626,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": { @@ -324063,6 +326966,265 @@ "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 + }, + "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.", @@ -325221,7 +328383,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 write-only `mac_address` shortcut\nfield for creating/updating the primary MACAddress in a single request.", "properties": { "device": { "oneOf": [ @@ -325270,6 +328432,7 @@ "virtual", "bridge", "lag", + "channel", "100base-fx", "100base-lfx", "100base-tx", @@ -325485,8 +328648,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-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 (250 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* `other` - Other", - "x-spec-enum-id": "b067eb1f050c6ae9" + "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-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 (250 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* `other` - Other", + "x-spec-enum-id": "ab7c1626812ec9d4" + }, + "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" @@ -325512,6 +328689,11 @@ "minimum": 1, "nullable": true }, + "mac_address": { + "type": "string", + "nullable": true, + "minLength": 1 + }, "primary_mac_address": { "oneOf": [ { @@ -326021,6 +329203,7 @@ "virtual", "bridge", "lag", + "channel", "100base-fx", "100base-lfx", "100base-tx", @@ -326236,8 +329419,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-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 (250 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* `other` - Other", - "x-spec-enum-id": "b067eb1f050c6ae9" + "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-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 (250 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* `other` - Other", + "x-spec-enum-id": "ab7c1626812ec9d4" + }, + "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" @@ -326250,6 +329447,11 @@ "type": "string", "maxLength": 200 }, + "parent": { + "type": "integer", + "nullable": true, + "title": "Parent interface" + }, "bridge": { "type": "integer", "nullable": true, @@ -326898,6 +330100,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 @@ -326905,6 +330113,12 @@ "attributes": { "nullable": true }, + "module_bay_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BriefModuleBayTypeRequest" + } + }, "owner": { "oneOf": [ { @@ -329891,7 +333105,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 write-only `mac_address` shortcut\nfield for creating/updating the primary MACAddress in a single request.", "properties": { "virtual_machine": { "oneOf": [ @@ -329927,6 +333141,11 @@ "minimum": 1, "nullable": true }, + "mac_address": { + "type": "string", + "nullable": true, + "minLength": 1 + }, "primary_mac_address": { "oneOf": [ { @@ -330411,7 +333630,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/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/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..9469257c3 100644 --- a/docs/configuration/miscellaneous.md +++ b/docs/configuration/miscellaneous.md @@ -277,7 +277,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 +309,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..b9a72518e 100644 --- a/docs/configuration/required-parameters.md +++ b/docs/configuration/required-parameters.md @@ -39,7 +39,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 +59,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 +251,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 38dcf5d51..044e6c4a5 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). --- diff --git a/docs/configuration/system.md b/docs/configuration/system.md index 845a40b68..40c535792 100644 --- a/docs/configuration/system.md +++ b/docs/configuration/system.md @@ -12,6 +12,20 @@ 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. + +```python +BULK_UPDATE_CHUNK_SIZE = 5000 +``` + +--- + ## DATABASE_ROUTERS Default: `[]` (empty list) @@ -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 diff --git a/docs/customization/custom-fields.md b/docs/customization/custom-fields.md index 2255fe3ea..9ee44f57f 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 @@ -109,6 +109,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 +142,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/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 59772d192..ed4ee0656 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. @@ -87,11 +89,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 @@ -99,7 +101,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..7b2ff9cec 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 "New 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..a894a2d77 --- /dev/null +++ b/docs/features/cooling.md @@ -0,0 +1,41 @@ +# Cooling + +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/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..8c5a538ad 100644 --- a/docs/installation/1-postgresql.md +++ b/docs/installation/1-postgresql.md @@ -2,8 +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 15 or later required" + NetBox requires PostgreSQL 15 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. @@ -15,7 +15,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 +35,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..3aacd7677 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -22,21 +22,21 @@ 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 @@ -58,7 +58,11 @@ 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 +## 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 +77,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 +120,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 +139,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 +173,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 +183,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/rest-api.md b/docs/integrations/rest-api.md index b7fcbd888..6005cdb7f 100644 --- a/docs/integrations/rest-api.md +++ b/docs/integrations/rest-api.md @@ -741,6 +741,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 +831,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..2fe16de79 100644 --- a/docs/models/dcim/cable.md +++ b/docs/models/dcim/cable.md @@ -34,7 +34,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..cf5df9b88 100644 --- a/docs/models/dcim/interface.md +++ b/docs/models/dcim/interface.md @@ -28,11 +28,17 @@ 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. !!! 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 + +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-type 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). @@ -78,11 +84,18 @@ 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. Channel-type subinterfaces are likewise bound to their [channelized](#channels) parent interface. !!! note An interface with one or more child interfaces assigned cannot be deleted until all its child interfaces have been deleted or reassigned. +### Channel ID + +For a channel-type subinterface, the numeric channel on its [channelized](#channels) parent interface to which this subinterface is bound. 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 Interfaces can be bridged to other interfaces on a device in two manners: symmetric or grouped. diff --git a/docs/models/dcim/module.md b/docs/models/dcim/module.md index 060c2b094..b2470392e 100644 --- a/docs/models/dcim/module.md +++ b/docs/models/dcim/module.md @@ -4,6 +4,16 @@ 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 + +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, 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. + +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 +50,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..93b5f8b21 100644 --- a/docs/models/dcim/modulebaytemplate.md +++ b/docs/models/dcim/modulebaytemplate.md @@ -1,3 +1,5 @@ # 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. diff --git a/docs/models/dcim/modulebaytype.md b/docs/models/dcim/modulebaytype.md new file mode 100644 index 000000000..7652ebecb --- /dev/null +++ b/docs/models/dcim/modulebaytype.md @@ -0,0 +1,35 @@ +# Module Bay Types + +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..993c5cbfe 100644 --- a/docs/models/dcim/moduletype.md +++ b/docs/models/dcim/moduletype.md @@ -75,10 +75,22 @@ 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. + ### 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/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..37b9368e8 100644 --- a/docs/models/extras/customfield.md +++ b/docs/models/extras/customfield.md @@ -109,6 +109,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/service.md b/docs/models/ipam/service.md index fc6ab73d2..a08267fc8 100644 --- a/docs/models/ipam/service.md +++ b/docs/models/ipam/service.md @@ -23,14 +23,62 @@ The parent object to which the application service is assigned. This must be one 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"`. Protocols may be specified in any case. diff --git a/docs/models/ipam/servicetemplate.md b/docs/models/ipam/servicetemplate.md index 9dd69b3c4..84d0bf80b 100644 --- a/docs/models/ipam/servicetemplate.md +++ b/docs/models/ipam/servicetemplate.md @@ -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/plugins/development/config-templates.md b/docs/plugins/development/config-templates.md new file mode 100644 index 000000000..61a4b5067 --- /dev/null +++ b/docs/plugins/development/config-templates.md @@ -0,0 +1,115 @@ +# Jinja Config Templates + +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..f795ffa02 --- /dev/null +++ b/docs/plugins/development/event-rule-actions.md @@ -0,0 +1,62 @@ +# Event Rule Actions + +[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/forms.md b/docs/plugins/development/forms.md index afe05407e..d5488c3a3 100644 --- a/docs/plugins/development/forms.md +++ b/docs/plugins/development/forms.md @@ -210,6 +210,35 @@ In addition to the [form fields provided by Django](https://docs.djangoproject.c options: members: false +## Static Choice Fields + +These fields render a standard HTML `' + '', + url, get_token(request), _('Set as primary'), + ) + html_str = str(html) + if '' in html_str: + html = mark_safe(html_str.replace('', str(form_li) + '', 1)) + + return html + + class MACAddressTable(PrimaryModelTable): mac_address = tables.TemplateColumn( template_code=MACADDRESS_LINK, @@ -1241,7 +1295,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 bc7d38a9d..cf47fcc64 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": 15, "modulebay:list_objects_with_permission": 18, - "modulebaytemplate:api_list_objects": 11, - "moduletype:api_list_objects": 13, + "modulebaytemplate:api_list_objects": 12, + "modulebaytype:api_list_objects": 13, + "modulebaytype:list_objects_with_permission": 18, + "moduletype:api_list_objects": 14, "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 837db1237..3ec60bda7 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -148,6 +148,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', @@ -464,6 +467,37 @@ 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_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') + class LocationTestCase(APIViewTestCases.APIViewTestCase): model = Location @@ -645,6 +679,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',) @@ -684,12 +720,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, @@ -938,6 +977,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', ) @@ -969,6 +1009,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, @@ -984,6 +1025,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', ) @@ -1011,6 +1053,7 @@ class ModuleTypeTestCase(APIViewTestCases.APIViewTestCase): { 'manufacturer': manufacturers[1].pk, 'model': 'Module Type 5', + 'end_of_life': '2035-06-30', }, { 'manufacturer': manufacturers[1].pk, @@ -1083,6 +1126,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'] @@ -1293,9 +1377,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 = [ { @@ -1318,6 +1404,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, + }, ] @@ -1883,16 +1984,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. @@ -2044,7 +2135,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) @@ -2072,7 +2163,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) @@ -2168,6 +2259,39 @@ 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. + DeviceViewSet uses SequentialBulkCreatesMixin, so 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]) + class ModuleTestCase(APIViewTestCases.APIViewTestCase): model = Module @@ -2241,6 +2365,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 @@ -2503,6 +2683,72 @@ 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) + class ConsolePortTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsolePort @@ -2699,9 +2945,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), @@ -2789,6 +3037,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 = {}): @@ -2887,6 +3150,93 @@ 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) + class FrontPortTestCase(APIViewTestCases.APIViewTestCase): model = FrontPort @@ -3164,6 +3514,52 @@ 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']) + class DeviceBayTestCase(APIViewTestCases.APIViewTestCase): model = DeviceBay @@ -3847,6 +4243,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'] @@ -4011,3 +4727,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..143f63276 --- /dev/null +++ b/netbox/dcim/tests/test_channelization.py @@ -0,0 +1,630 @@ +from django.core.exceptions import ValidationError +from django.test import TestCase +from django.urls import reverse + +from dcim.choices import CableProfileChoices, InterfaceTypeChoices +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 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)) + + +class ChannelizedInterfaceValidationTestCase(TestCase): + """ + Test validation of the channels and channel_id fields on Interface. + """ + + @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 + ) + + 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_requires_channel_type(self): + interface = Interface( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS, + parent=self.parent, channel_id=1 + ) + with self.assertRaises(ValidationError): + interface.full_clean() + + 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() + + +class ChannelizedInterfaceTemplateTestCase(TestCase): + """ + Test that the channels, channel_id, and parent fields are replicated from InterfaceTemplate to the Interfaces + instantiated for a new Device, and that parent interfaces are populated before their channel subinterfaces. + """ + + @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') + + # A channelized parent template broken out into four channel subinterface templates bound to it + parent_template = InterfaceTemplate.objects.create( + device_type=cls.device_type, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4 + ) + for i in range(1, 5): + InterfaceTemplate.objects.create( + device_type=cls.device_type, + name=f'et0:{i}', + type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=parent_template, + channel_id=i, + ) + + def test_channelization_replicated_on_instantiation(self): + device = Device.objects.create( + site=self.site, device_type=self.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) + + 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' + ) + foreign_parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0') + template = InterfaceTemplate( + device_type=other_type, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=foreign_parent, channel_id=1 + ) + with self.assertRaises(ValidationError): + template.full_clean() + + def test_template_parent_channel_id_must_be_unique(self): + parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0') + # Channel 1 already exists on the parent (created in setUpTestData) + duplicate = InterfaceTemplate( + device_type=self.device_type, name='et0:1b', type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=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 + parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0') + template = InterfaceTemplate( + device_type=self.device_type, name='et0:5', type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=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_channel_type(self): + # A channel_id on a non-channel-type template is rejected + 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): + # Reducing a parent template's channel count below a bound child template's channel_id is rejected (channels + # 3 & 4 are bound in setUpTestData) + parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0') + parent.channels = 2 + with self.assertRaises(ValidationError): + 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 + parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0') + parent.channels = None + with self.assertRaises(ValidationError): + parent.full_clean() + + +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 7bf03075e..753628e9e 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,102 @@ class ModuleTypeFormTestCase(TestCase): self.assertEqual(module_type.attribute_data, {'media': ['copper', 'qsfp28']}) +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)) + + class VCPositionTokenFormTestCase(TestCase): @classmethod @@ -553,12 +653,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 772036ecc..e7f9de608 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -12,7 +12,7 @@ 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 virtualization.models import Cluster, ClusterType @@ -243,6 +243,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): @@ -262,7 +264,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) @@ -275,6 +277,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): @@ -1061,9 +1066,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() @@ -1144,18 +1150,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, @@ -1163,8 +1168,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) } @@ -1172,18 +1177,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. """ @@ -1273,15 +1276,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() @@ -1316,19 +1321,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() @@ -1356,7 +1362,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 @@ -1367,10 +1374,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() @@ -1449,6 +1456,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.""" @@ -1942,6 +1981,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 @@ -2160,6 +2353,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): """ @@ -2923,3 +3207,552 @@ class PowerPortDrawTestCase(TestCase): self.assertEqual(legs_by_name['A']['maximum'], 200) self.assertEqual(legs_by_name['B']['allocated'], 0) self.assertEqual(legs_by_name['C']['allocated'], 0) + + +class InventoryItemCycleTestCase(TestCase): + """ + 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') + 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 test_cooling_method_inherited_from_device_type(self): + """ + 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( + site=self.site, + device_type=device_type, + role=self.role, + name='Device 1' + ) + self.assertEqual(device.cooling_method, CoolingMethodChoices.METHOD_LIQUID) + + def test_cooling_method_not_overridden_when_set(self): + """ + A new Device with an explicitly-set cooling_method should not be overridden by the DeviceType. + """ + device_type = DeviceType.objects.create( + manufacturer=self.manufacturer, + model='Device Type 2', + slug='device-type-2', + cooling_method=CoolingMethodChoices.METHOD_LIQUID + ) + 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) + + 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' + ) + + 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( + site=self.site, + device_type=device_type, + role=self.role, + name='Device 3' + ) + + 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) + + CoolingOutflow.objects.get( + device=device, + name='Cooling Outlet 1', + type=CoolingConnectorTypeChoices.TYPE_UQD, + diameter=Decimal('25'), + diameter_unit=DiameterUnitChoices.UNIT_MILLIMETER + ) + + def test_cooling_choice_colors_resolve(self): + """ + 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. + """ + 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( + site=self.site, device_type=device_type, role=self.role, name='Device J' + ) + + 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 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_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 + ) + + 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..b8b7a7780 --- /dev/null +++ b/netbox/dcim/tests/test_module_moves.py @@ -0,0 +1,1447 @@ +import signal +from contextlib import contextmanager +from unittest.mock import patch + +from django.core.exceptions import ValidationError +from django.db import IntegrityError, OperationalError, connection, router, transaction +from django.test import TestCase +from django.test.utils import CaptureQueriesContext + +from circuits.models import Provider, ProviderNetwork, VirtualCircuit, VirtualCircuitTermination, VirtualCircuitType +from dcim.choices import InterfaceModeChoices, InterfaceTypeChoices, ModuleStatusChoices, PortTypeChoices +from dcim.models import ( + Cable, + Device, + 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.module_moves import 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 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_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() + + 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_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)) 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 586511752..768f2cad1 100644 --- a/netbox/dcim/tests/test_signals.py +++ b/netbox/dcim/tests/test_signals.py @@ -1,16 +1,17 @@ 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 -from django.test.utils import CaptureQueriesContext +from django.db import connection +from django.test import SimpleTestCase, TestCase from dcim import signals from dcim.choices import CableEndChoices, CableProfileChoices, LinkStatusChoices from dcim.models import ( Cable, CablePath, + CableTermination, Device, DeviceRole, DeviceType, @@ -23,12 +24,14 @@ from dcim.models import ( PowerPanel, Rack, RearPort, - Region, Site, 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 virtualization.models import Cluster, ClusterType from wireless.models import WirelessLAN @@ -119,10 +122,11 @@ class RackSiteChangeSignalTestCase(TestCase): self.assertEqual(interface._location, self.location_b) -class DeviceSiteChangeSignalTestCase(TestCase): +class DeviceComponentScopeTriggerTestCase(TestCase): """ - Verify dcim.signals.handle_device_site_change propagates a Device's site/location/rack - to its components on save. + 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. """ @classmethod @@ -149,6 +153,25 @@ class DeviceSiteChangeSignalTestCase(TestCase): interface.refresh_from_db() self.assertEqual(interface._site, self.site_b) + 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 = Interface.objects.create(device=device, name='Interface 1') + self.assertEqual(interface._site, self.site_a) + + Device.objects.filter(pk=device.pk).update(site=self.site_b) + + interface.refresh_from_db() + self.assertEqual(interface._site, self.site_b) + class VirtualChassisMasterSignalTestCase(TestCase): """ @@ -418,10 +441,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): @@ -461,11 +486,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) @@ -488,348 +511,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) - - -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): """ @@ -861,3 +542,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('/', 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..8cf95ed2f 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1,5 +1,6 @@ from django.conf import settings from django.contrib import messages +from django.contrib.auth.views import redirect_to_login from django.contrib.contenttypes.models import ContentType from django.core.paginator import EmptyPage, PageNotAnInteger from django.db import router, transaction @@ -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, @@ -249,6 +252,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 +391,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 +681,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 +1123,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 +1278,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')), @@ -1420,6 +1452,9 @@ class DeviceTypeListView(generic.ObjectListView): 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 +1470,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 +1549,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 +1664,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 +1677,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 +1693,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 +1731,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 # @@ -1770,6 +1907,9 @@ class ModuleTypeListView(generic.ObjectListView): 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 +1929,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 +1942,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 +2035,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 +2120,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 +2133,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 +2147,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 +2170,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 +2380,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 # @@ -2435,6 +2747,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 +2854,9 @@ 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')), + ], left_panels=[ panels.PlatformPanel(), TagsPanel(), @@ -2647,8 +2968,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 +3074,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 +3204,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 +3313,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 +3390,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 +3488,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 +3582,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 +3675,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 +3753,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 +3923,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 +3954,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 +4107,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 +4215,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 +4321,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 +4354,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 +4396,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 +4454,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 +4612,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 +4818,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 +5386,13 @@ 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('location'), + ], left_panels=[ panels.PowerPanelPanel(), TagsPanel(), @@ -4826,8 +5475,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 +5549,170 @@ 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('location'), + ], + 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 +5842,43 @@ class MACAddressDeleteView(generic.ObjectDeleteView): queryset = MACAddress.objects.all() +@register_model_view(MACAddress, 'set_primary') +class MACAddressSetPrimaryView(View): + queryset = MACAddress.objects.all() + + def post(self, request, pk): + if not request.user.is_authenticated: + return redirect_to_login(request.get_full_path()) + + 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()) + + perm = get_permission_for_model(assigned_object, 'change') + if not request.user.has_perm(perm): + messages.error( + request, + _('You do not have permission to modify {object}.').format(object=assigned_object) + ) + return redirect(mac.get_absolute_url()) + + if assigned_object.primary_mac_address_id != mac.pk: + assigned_object.snapshot() + assigned_object.primary_mac_address = mac + assigned_object.save() + messages.success( + request, + _('Set {mac} as primary MAC address for {interface}.').format( + mac=mac, interface=assigned_object + ) + ) + + return redirect(assigned_object.get_absolute_url()) + + @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..96891c14a 100644 --- a/netbox/extras/api/serializers_/customfields.py +++ b/netbox/extras/api/serializers_/customfields.py @@ -69,7 +69,7 @@ class CustomFieldSerializer(OwnerMixin, ChangeLogMessageSerializer, ValidatedMod 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', ] 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/api/views.py b/netbox/extras/api/views.py index d7c21c86b..85dc5eca1 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -22,6 +22,7 @@ from netbox.api.metadata import ContentTypeMetadata from netbox.api.renderers import TextRenderer from netbox.api.viewsets import BaseViewSet, NetBoxModelViewSet from netbox.api.viewsets.mixins import ObjectValidationMixin +from users.models import Token from utilities.exceptions import RQWorkerNotRunningException from utilities.request import copy_safe_request from utilities.rqworker import any_workers_for_queue @@ -352,6 +353,12 @@ class ScriptViewSet(ModelViewSet): Run a Script identified by its numeric PK or module & name and return the pending Job as the result """ + # Running a script is a state-changing operation. If token authentication is in use, enforce the token's + # write ability before performing any object lookup. Session-authenticated requests are unaffected + # (request.auth is not a Token). + if isinstance(request.auth, Token) and not request.auth.write_enabled: + raise PermissionDenied("This token does not permit write operations.") + script = self._get_script(pk) if not request.user.has_perm('extras.run_script', obj=script): 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..f2ee6923e --- /dev/null +++ b/netbox/extras/cache.py @@ -0,0 +1,146 @@ +""" +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 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): + """ + 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. + """ + pks = list(pks) + if not pks: + return + + Model = apps.get_model(model_label) + updated = chunked_update( + Model.objects.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) + ) + + +def invalidate_config_context_for_configcontext(configcontext): + """ + Invalidate caches for all objects currently in scope for the given ConfigContext. + """ + for queryset in configcontext.get_affected_objects(): + invalidate_config_context_for_objects( + queryset.model._meta.label_lower, + queryset.values_list('pk', flat=True), + ) + + +def invalidate_for_scope_delta(scope_field, scope_pks): + """ + 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. + """ + 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.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.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.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.filter(device_q).values_list('pk', flat=True) + ) + if vm_q is not None: + invalidate_config_context_for_objects( + 'virtualization.virtualmachine', VirtualMachine.objects.filter(vm_q).values_list('pk', flat=True) + ) diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index b011819d4..79bd51097 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,19 +27,23 @@ 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')), ) @@ -50,9 +54,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 +67,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 +80,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 +93,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 +116,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 +142,7 @@ class CustomLinkButtonClassChoices(ButtonColorChoices): CHOICES = ( *ButtonColorChoices.CHOICES, - (LINK, _('Link')), + Choice(LINK, _('Link'), description=_('Render the button as a borderless text link')), ) @@ -154,10 +158,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 +178,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 +198,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 +228,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 +256,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 +276,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..b1a34b80f 100644 --- a/netbox/extras/conditions.py +++ b/netbox/extras/conditions.py @@ -13,6 +13,12 @@ __all__ = ( AND = 'and' OR = 'or' +# Sentinel for a snapshot attribute that could not be resolved (missing key or +# null snapshot). Using a unique object ensures that two independently +# unresolvable values compare equal to each other, which is the correct +# semantics for the 'unchanged' operator when neither snapshot has the field. +_MISSING = object() + def is_ruleset(data): """ @@ -30,8 +36,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 +48,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,25 +67,44 @@ 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('snapshots.'): + 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 eval(self, data): + def _resolve_attr(self, data): """ - Evaluate the provided data to determine whether it matches the condition. + 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). """ def _get(obj, key): if isinstance(obj, list): @@ -81,9 +112,46 @@ class Condition: return operator.getitem(obj or {}, key) try: - value = functools.reduce(_get, self.attr.split('.'), data) + return functools.reduce(_get, self.attr.split('.'), data) except KeyError: raise InvalidCondition(f"Invalid key path: {self.attr}") + except TypeError as e: + raise InvalidCondition(f"Invalid key path: {self.attr} ({e})") + + def _resolve_snapshot_attr(self, snapshot): + """ + Walk self.attr through a snapshot dict, returning _MISSING on any miss. + Snapshots use the model serializer format (raw field values), not the REST + API format, so e.g. status is stored as "active" not {"value": "active"}. + """ + if snapshot is None: + return _MISSING + try: + obj = snapshot + for key in self.attr.split('.'): + if isinstance(obj, list): + obj = [operator.getitem(item or {}, key) for item in obj] + else: + obj = operator.getitem(obj or {}, key) + return obj + except (KeyError, TypeError): + return _MISSING + + def eval(self, data): + """ + Evaluate the provided data to determine whether it matches the condition. + """ + if self.op in self.SNAPSHOT_OPERATORS: + snapshots = data.get('snapshots') if isinstance(data, dict) else None + if snapshots is None: + 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 + + value = self._resolve_attr(data) try: result = self.eval_func(value) except TypeError as e: @@ -128,6 +196,27 @@ class Condition: def eval_regex(self, value): return re.match(self.value, value) is not None + # Snapshot comparison operators + # These resolve self.attr in both the prechange and postchange snapshots and + # compare the resulting values. _MISSING is used when a snapshot is absent + # or does not contain the attribute. + # + # Fail-closed semantics: + # changed: False when attr is absent from both snapshots (field never existed) + # unchanged: False when attr is absent from both snapshots (avoids silent pass on typos) + + def eval_changed(self, snapshots): + pre = self._resolve_snapshot_attr(snapshots.get('prechange')) + post = self._resolve_snapshot_attr(snapshots.get('postchange')) + return pre != post + + def eval_unchanged(self, snapshots): + pre = self._resolve_snapshot_attr(snapshots.get('prechange')) + post = self._resolve_snapshot_attr(snapshots.get('postchange')) + if pre is _MISSING and post is _MISSING: + return False + return pre == post + class ConditionSet: """ diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index a0a33936b..3db1a1d21 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -6,14 +6,6 @@ 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 - # ImageAttachment IMAGE_ATTACHMENT_IMAGE_FORMATS = { 'avif': 'image/avif', @@ -214,3 +206,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..4e5803e34 --- /dev/null +++ b/netbox/extras/event_rules.py @@ -0,0 +1,108 @@ +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', +) + + +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 + ScriptJob.enqueue(**params) + + 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 e4740779e..2c64c1d30 100644 --- a/netbox/extras/events.py +++ b/netbox/extras/events.py @@ -2,22 +2,15 @@ import logging from collections import UserDict, defaultdict from django.conf import settings -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 .models import EventRule logger = logging.getLogger('netbox.events_processor') @@ -154,9 +147,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. @@ -170,21 +160,26 @@ 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. + - 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. """ + # 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 + for event_rule in event_rules: - # Evaluate event rule conditions (if any) - if not event_rule.eval_conditions(event['data']): + # Evaluate event rule conditions (if any). + # Snapshots are merged into the condition context so conditions can + # reference snapshots.prechange. and snapshots.postchange. + # using the standard dot-path syntax, and so the 'changed'/'unchanged' + # operators can access pre/post values. + condition_data = {**event['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 @@ -208,76 +203,35 @@ def process_event_rules(event_rules, object_type, event): # Copy to avoid mutating the rule's stored action_data dict. event_data = {**action_data, **event['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 - ScriptJob.enqueue(**params) - - # 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'], + 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, + ) ) + 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..eab172b22 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', ) def search(self, queryset, name, value): diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index abfe22f5a..9079f1b5d 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): @@ -429,7 +440,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..97b8da4d0 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 @@ -47,7 +48,7 @@ class CustomFieldFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm): FieldSet('q', 'filter_id'), FieldSet('object_type_id', 'type', 'group_name', 'weight', 'required', 'unique', 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')), ) @@ -110,6 +111,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 +316,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 +334,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 +351,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 +367,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..41e00f9ce 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') ), ) @@ -107,7 +139,8 @@ class CustomFieldForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm): 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 +221,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 +341,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 +575,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 +600,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 +631,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: @@ -586,45 +642,31 @@ class EventRuleForm(OwnerMixin, NetBoxModelForm): ) 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 +674,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 +923,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..fc9636d5f 100644 --- a/netbox/extras/graphql/enums.py +++ b/netbox/extras/graphql/enums.py @@ -1,6 +1,10 @@ +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', @@ -23,6 +27,10 @@ CustomFieldTypeEnum = strawberry.enum(CustomFieldTypeChoices.as_enum(prefix='typ 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..bdd17a44c 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() @@ -193,10 +193,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 +239,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 +257,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 +271,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 +289,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 +305,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 +320,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 +328,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 +345,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 +357,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 +370,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 +380,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 +396,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 7514c1be0..c1184c635 100644 --- a/netbox/extras/jobs.py +++ b/netbox/extras/jobs.py @@ -2,6 +2,7 @@ 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 _ @@ -15,6 +16,97 @@ from utilities.exceptions import AbortScript, AbortTransaction from .utils import is_report +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 + 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/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/models/configs.py b/netbox/extras/models/configs.py index 875ea854b..cf08157d3 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,133 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, OwnerMixin, self.data = self.data_file.get_data() sync_data.alters_data = True + def get_affected_objects(self): + """ + 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. + """ + from dcim.models import Device + from virtualization.models import VirtualMachine + + device_q, vm_q = self._get_affected_object_filters() + return ( + Device.objects.filter(device_q), + VirtualMachine.objects.filter(vm_q), + ) + + def _get_affected_object_filters(self): + """ + 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). + """ + 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.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.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.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.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 +359,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 +406,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..694bc5750 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -8,7 +8,7 @@ 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 import models from django.db.models import F, Func, Value from django.urls import reverse from django.utils.html import escape @@ -18,7 +18,6 @@ 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.context import query_cache @@ -43,9 +42,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', @@ -79,7 +78,9 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): 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') + custom_fields = 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: @@ -261,6 +262,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 +278,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: @@ -324,39 +331,19 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo return self.choice_set.get_choice_color(value) return None - @staticmethod - def _update_object_data(model, filters=None, **update_kwargs): + def resolve_selection_value(self, value): """ - 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. + 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). """ - 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] - ) - if not pks: - break - queryset.filter(pk__in=pks).update(**update_kwargs) - last_pk = pks[-1] + 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 def populate_initial_data(self, content_types): """ @@ -374,8 +361,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo value = Value(self.default, models.JSONField()) for ct in content_types: if model := ct.model_class(): - self._update_object_data( - model, + chunked_update( + model.objects.all(), custom_field_data=Func( F('custom_field_data'), Value([self.name]), @@ -395,9 +382,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo """ for ct in content_types: if model := ct.model_class(): - self._update_object_data( - model, - filters={'custom_field_data__has_key': self.name}, + chunked_update( + model.objects.filter(custom_field_data__has_key=self.name), custom_field_data=F('custom_field_data') - self.name ) @@ -408,9 +394,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo """ for ct in self.object_types.all(): if model := ct.model_class(): - self._update_object_data( - model, - filters={'custom_field_data__has_key': old_name}, + chunked_update( + model.objects.filter(custom_field_data__has_key=old_name), custom_field_data=Func( F('custom_field_data') - old_name, Value([new_name]), @@ -818,6 +803,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 +874,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 fdb371a9f..888387d49 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..bb3820e3f 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 """ @@ -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..6df3ddb66 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -12,7 +12,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 # @@ -102,6 +108,289 @@ 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, **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) + + +@receiver(pre_delete, sender=ConfigContext) +def invalidate_on_configcontext_delete(sender, instance, **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) + + +def invalidate_on_configcontext_m2m_change(sender, instance, action, pk_set, scope_field, **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) + + # 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) + + +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, _field=field_name, **kwargs): + invalidate_on_configcontext_m2m_change( + sender=sender, + instance=instance, + action=action, + pk_set=pk_set, + scope_field=_field, + **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, **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]) + + 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, **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]) + elif isinstance(instance, VirtualMachine): + invalidate_config_context_for_objects('virtualization.virtualmachine', [instance.pk]) + + +# 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, **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.filter(**{device_lookup: instance.pk}).values_list('pk', flat=True), + ) + if vm_lookup: + invalidate_config_context_for_objects( + 'virtualization.virtualmachine', + VirtualMachine.objects.filter(**{vm_lookup: instance.pk}).values_list('pk', flat=True), + ) + + return _handler + + +def _make_reparent_handler(device_attr, vm_attr): + def _handler(sender, instance, created, **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.filter(pk=instance.pk).values_list('path', flat=True).first() + if node_path is None: + return + subtree_pks = list( + model.objects.filter(path__descendant_or_equal=node_path).values_list('pk', flat=True) + ) + + if device_attr: + invalidate_config_context_for_objects( + 'dcim.device', + Device.objects.filter(**{device_attr: subtree_pks}).values_list('pk', flat=True), + ) + if vm_attr: + invalidate_config_context_for_objects( + 'virtualization.virtualmachine', + VirtualMachine.objects.filter(**{vm_attr: subtree_pks}).values_list('pk', flat=True), + ) + + 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), + ('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, **kwargs): + invalidate_for_scope_delta(scope_field, [instance.pk]) + + 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/tables.py b/netbox/extras/tables/tables.py index fac030061..120e63a8d 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -112,6 +112,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,8 +139,8 @@ 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', 'comments', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'object_types', 'label', 'group_name', 'type', 'required', 'unique', 'description', @@ -487,6 +491,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 +506,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 +555,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 89af94be6..c7e85b903 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -16,10 +16,13 @@ from core.choices import ManagedFileRootPathChoices from core.events import * from core.models import DataFile, DataSource, 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 @@ -43,6 +46,7 @@ class WebhookTestCase(APIViewTestCases.APIViewTestCase): { 'name': 'Webhook 4', 'payload_url': 'http://example.com/?4', + 'timeout': 15, }, { 'name': 'Webhook 5', @@ -156,6 +160,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'] @@ -178,6 +305,7 @@ class CustomFieldTestCase(APIViewTestCases.APIViewTestCase): ] bulk_update_data = { 'description': 'New description', + 'nulls_first': False, } update_data = { 'object_types': ['dcim.device'], @@ -196,7 +324,8 @@ class CustomFieldTestCase(APIViewTestCases.APIViewTestCase): ), CustomField( name='cf2', - type='integer' + type='integer', + nulls_first=False ), CustomField( name='cf3', @@ -1473,6 +1602,45 @@ class ScriptTestCase(APITestCase): # Restore the original setting for other tests self.TestScriptClass.Meta.scheduling_enabled = original + def test_run_token_write_enabled(self): + """ + Running a script is an unsafe (state-changing) action and must be rejected when the calling token has + write_enabled=False, even if the user holds the run_script permission. + """ + self.add_permissions('extras.run_script') + payload = { + 'data': {'var1': 'hello', 'var2': 1, 'var3': False}, + 'commit': True, + } + + # A token with write_enabled=False should be rejected + token = Token.objects.create(version=2, user=self.user, write_enabled=False) + token_header = f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}' + response = self.client.post(self.url, payload, format='json', HTTP_AUTHORIZATION=token_header) + self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN) + + # Enabling write ability on the token should allow the script to run + token.write_enabled = True + token.save() + response = self.client.post(self.url, payload, format='json', HTTP_AUTHORIZATION=token_header) + self.assertHttpStatus(response, status.HTTP_200_OK) + + def test_run_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 allowed 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) + class CreatedUpdatedFilterTestCase(APITestCase): diff --git a/netbox/extras/tests/test_conditions.py b/netbox/extras/tests/test_conditions.py index 53b3c6ac3..b5dc6caeb 100644 --- a/netbox/extras/tests/test_conditions.py +++ b/netbox/extras/tests/test_conditions.py @@ -321,3 +321,249 @@ class ConditionSetTestCase(TestCase): }) self.assertFalse(form.is_valid()) + + +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_false_when_both_snapshots_missing_attr(self): + # If neither snapshot has the attr, nothing changed + c = Condition('nonexistent', op='changed') + snapshots = { + 'prechange': {'status': 'active'}, + 'postchange': {'status': 'active'}, + } + self.assertFalse(c.eval({'snapshots': snapshots})) + + def test_changed_false_when_path_traverses_scalar(self): + # Snapshot choice fields are raw strings, not nested dicts. A path like + # 'status.value' hits a TypeError when traversing into the string; both + # sides resolve to _MISSING and the operator returns False (no change). + c = Condition('status.value', op='changed') + snapshots = { + 'prechange': {'status': 'planned'}, + 'postchange': {'status': 'active'}, + } + self.assertFalse(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})) + + def test_changed_raises_when_no_snapshots(self): + c = Condition('status', op='changed') + with self.assertRaises(InvalidCondition): + c.eval({'status': {'value': 'active'}}) + + # + # '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_false_when_both_snapshots_missing_attr(self): + # Fail-closed: a typo or non-existent attr resolves to _MISSING on both + # sides; unchanged must return False rather than silently passing. + c = Condition('statsu', op='unchanged') + snapshots = { + 'prechange': {'status': 'active'}, + 'postchange': {'status': 'active'}, + } + self.assertFalse(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}) + + # + # 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)) diff --git a/netbox/extras/tests/test_configcontext_cache.py b/netbox/extras/tests/test_configcontext_cache.py new file mode 100644 index 000000000..82a673727 --- /dev/null +++ b/netbox/extras/tests/test_configcontext_cache.py @@ -0,0 +1,709 @@ +from unittest import mock + +from django.db import connection +from django.db.models import F +from django.test import TestCase +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_objects +from extras.jobs import RenderConfigContextJob +from extras.models import ConfigContext, Tag +from tenancy.models import Tenant, TenantGroup +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_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) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 3a452edce..5e07ba5cd 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -8,7 +8,7 @@ import django_filters from django.core.exceptions import ValidationError from django.db import connection from django.db.models import QuerySet -from django.test import tag +from django.test import override_settings, tag from django.test.utils import CaptureQueriesContext from django.urls import reverse from rest_framework import status @@ -24,6 +24,7 @@ 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.tables.columns import CustomFieldColumn from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter from utilities.testing import APITestCase, TestCase from virtualization.models import VirtualMachine @@ -78,6 +79,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,7 +683,7 @@ 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 @@ -845,47 +890,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 +922,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 @@ -1264,6 +1234,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 +1325,167 @@ 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') + query = '{ site_list { custom_fields } }' + + Site.objects.bulk_create([Site(name=f'Site {i}', slug=f'site-{i}') for i in range(3, 8)]) + # Prime process-level caches (e.g. ContentType) outside the measured request. + self.client.post(reverse('graphql'), data={'query': query}, format='json', **self.header) + 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']), 7) + baseline_query_count = len(ctx.captured_queries) + + Site.objects.bulk_create([Site(name=f'Site {i}', slug=f'site-{i}') for i in range(8, 13)]) + 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) + + self.assertEqual( + len(ctx.captured_queries), baseline_query_count, + "custom_fields label resolution should not scale with the number of objects returned" + ) + + 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 +1514,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 +1579,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 +1645,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 +1725,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 +1782,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 +2013,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}) diff --git a/netbox/extras/tests/test_event_rules.py b/netbox/extras/tests/test_event_rules.py index d75bf5bf6..8e4674fb9 100644 --- a/netbox/extras/tests/test_event_rules.py +++ b/netbox/extras/tests/test_event_rules.py @@ -6,9 +6,11 @@ from unittest import skipIf from unittest.mock import Mock, 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, tag +from django.test import RequestFactory, TestCase, override_settings, tag from django.urls import reverse from PIL import Image from requests import Session @@ -20,12 +22,20 @@ from core.models import Job, ObjectType from dcim.choices import SiteStatusChoices from dcim.models import 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 from extras.webhooks import generate_signature, send_webhook 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 utilities.testing import APITestCase, create_test_device from utilities.testing.mixins import RQQueueTestMixin @@ -104,6 +114,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. @@ -386,13 +437,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 @@ -426,13 +478,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) @@ -454,7 +602,7 @@ 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 test_duplicate_enqueue_refreshes_lazy_payload(self): """ @@ -882,6 +1030,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..9f8ca454a 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', @@ -151,8 +153,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 +214,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 +227,7 @@ class WebhookTestCase(TestCase, BaseFilterSetTests): payload_url='http://example.com/?1', http_method='GET', ssl_verification=True, + timeout=10, description='foobar1' ), Webhook( @@ -226,6 +235,7 @@ class WebhookTestCase(TestCase, BaseFilterSetTests): payload_url='http://example.com/?2', http_method='POST', ssl_verification=True, + timeout=20, description='foobar2' ), Webhook( @@ -233,6 +243,7 @@ class WebhookTestCase(TestCase, BaseFilterSetTests): payload_url='http://example.com/?3', http_method='PATCH', ssl_verification=False, + timeout=30, description='foobar3' ), Webhook( @@ -270,8 +281,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 +403,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 +457,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 +526,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 +631,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 +700,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 +776,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 +870,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 +926,7 @@ class TableConfigTestCase(TestCase, ChangeLoggedFilterSetTests): ) -class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests): +class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTestMixin): queryset = JournalEntry.objects.all() filterset = JournalEntryFilterSet @@ -964,7 +1029,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 +1062,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 +1305,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 +1371,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 +1694,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..2481ea2e8 --- /dev/null +++ b/netbox/extras/tests/test_graphql.py @@ -0,0 +1,56 @@ +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 EventRuleActionChoices +from extras.graphql.enums import EventRuleActionEnum +from extras.models import 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'}) diff --git a/netbox/extras/tests/test_management_commands.py b/netbox/extras/tests/test_management_commands.py index 3f46f413b..8eef01ec3 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): @@ -413,6 +415,87 @@ class RunScriptTestCase(TestCase): self.assertEqual(enqueue.call_args.kwargs['user'], self.user) +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..1afdbf87d 100644 --- a/netbox/extras/tests/test_tables.py +++ b/netbox/extras/tests/test_tables.py @@ -1,4 +1,9 @@ -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.models import Bookmark, EventRule, Notification, Subscription from extras.tables import * from utilities.testing import TableTestCases @@ -69,6 +74,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 cdc6786f0..df998c0ad 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 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 97abd274a..6bcd3d4f5 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,139 @@ 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_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_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 +1359,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 +1402,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 fd0afffa1..fe16c143b 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -1,27 +1,26 @@ from django import forms from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist from django.utils.translation import gettext_lazy as _ 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, get_field_value +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.templatetags.builtins.filters import bettertitle +from utilities.forms.widgets import BulkEditNullBooleanSelect __all__ = ( 'ASNBulkEditForm', @@ -205,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 @@ -230,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 = ( @@ -249,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 @@ -296,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 @@ -323,7 +322,7 @@ class IPAddressBulkEditForm(PrimaryModelBulkEditForm): class FHRPGroupBulkEditForm(PrimaryModelBulkEditForm): - protocol = forms.ChoiceField( + protocol = ChoiceField( label=_('Protocol'), choices=add_blank_choice(FHRPGroupProtocolChoices), required=False @@ -333,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') @@ -357,19 +356,13 @@ class FHRPGroupBulkEditForm(PrimaryModelBulkEditForm): nullable_fields = ('auth_type', 'auth_key', 'name', 'description', 'comments') -class VLANGroupBulkEditForm(OrganizationalModelBulkEditForm): - scope_type = ContentTypeChoiceField( - queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), - widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}), - required=False, - label=_('Scope type') - ) - scope = DynamicModelChoiceField( +class VLANGroupBulkEditForm(GenericObjectFormMixin, OrganizationalModelBulkEditForm): + scope = GenericObjectChoiceField( label=_('Scope'), - queryset=Site.objects.none(), # Initial queryset + content_type_queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), required=False, - disabled=True, - selector=True + selector=True, + hx_method='post', ) vid_ranges = NumericRangeArrayField( label=_('VLAN ID ranges'), @@ -384,25 +377,11 @@ class VLANGroupBulkEditForm(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') - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - if scope_type_id := get_field_value(self, 'scope_type'): - try: - scope_type = ContentType.objects.get(pk=scope_type_id) - model = scope_type.model_class() - self.fields['scope'].queryset = model.objects.all() - self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower - self.fields['scope'].disabled = False - self.fields['scope'].label = _(bettertitle(model._meta.verbose_name)) - except ObjectDoesNotExist: - pass - class VLANBulkEditForm(PrimaryModelBulkEditForm): region = DynamicModelChoiceField( @@ -437,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 @@ -447,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 @@ -497,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..89dcdb645 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,7 @@ from dcim.models import Device, Interface, Site from ipam.choices import * from ipam.constants import * from ipam.models import * +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 +589,41 @@ 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. + """ + 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").') ) + def clean_port_mappings(self): + mappings = self.cleaned_data.get('port_mappings') + if not mappings: + return [] + # Strip surrounding whitespace from each CSV token; validate_port_mappings matches the protocol + # case-insensitively and returns the normalized (canonical) list, so protocols may be given in + # any case (e.g. "TCP/80") without folding here. + mappings = [mapping.strip() for mapping in mappings] + try: + mappings = validate_port_mappings(mappings) + except DjangoValidationError as exc: + raise forms.ValidationError(exc.messages) + return mappings + + +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 +640,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 +650,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..66a03afe1 --- /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 "Select 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 f6f4a17b9..4c0e0933a 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,70 +673,46 @@ class FHRPGroupAssignmentForm(forms.ModelForm): return group -class VLANGroupForm(TenancyForm, OrganizationalModelForm): +class VLANGroupForm(GenericObjectFormMixin, TenancyForm, OrganizationalModelForm): vid_ranges = NumericRangeArrayField( label=_('VLAN IDs') ) - scope_type = ContentTypeChoiceField( - queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), - widget=HTMXSelect(), - required=False, - label=_('Scope type') - ) - scope = DynamicModelChoiceField( + scope = GenericObjectChoiceField( label=_('Scope'), - queryset=Site.objects.none(), # Initial queryset + content_type_queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), required=False, - disabled=True, - selector=True + 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', ] - def __init__(self, *args, **kwargs): - instance = kwargs.get('instance') - initial = kwargs.get('initial', {}) - - if instance is not None and instance.scope: - initial['scope'] = instance.scope - kwargs['initial'] = initial - - super().__init__(*args, **kwargs) - - if scope_type_id := get_field_value(self, 'scope_type'): - try: - scope_type = ContentType.objects.get(pk=scope_type_id) - model = scope_type.model_class() - self.fields['scope'].queryset = model.objects.all() - self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower - self.fields['scope'].disabled = False - self.fields['scope'].label = _(bettertitle(model._meta.verbose_name)) - except ObjectDoesNotExist: - pass - - if self.instance and scope_type_id != self.instance.scope_type_id: - self.initial['scope'] = None - - def clean(self): - super().clean() - - # Assign the selected scope (if any) - self.instance.scope = self.cleaned_data.get('scope') - 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, @@ -787,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(), @@ -836,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): @@ -899,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', - 'comments', 'tags', 'parent_object_type', + 'service_template', 'name', 'port_mappings', 'ipaddresses', 'description', + '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 @@ -928,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