From b1439dc298e4988f95c1aeda6ca8c7caf56e3282 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 2 Sep 2025 15:38:32 -0400 Subject: [PATCH 001/125] Closes #19889: Drop support for Python 3.10 & 3.11 --- .github/ISSUE_TEMPLATE/02-bug_report.yaml | 2 -- .github/workflows/ci.yml | 2 +- docs/development/getting-started.md | 2 +- docs/installation/3-netbox.md | 8 ++++---- docs/installation/4a-gunicorn.md | 3 --- docs/installation/index.md | 2 +- netbox/netbox/settings.py | 4 ++-- netbox/utilities/datetime.py | 5 ----- pyproject.toml | 6 ++---- upgrade.sh | 8 ++++---- 10 files changed, 15 insertions(+), 27 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/02-bug_report.yaml b/.github/ISSUE_TEMPLATE/02-bug_report.yaml index f25b70b19..2a70c13b9 100644 --- a/.github/ISSUE_TEMPLATE/02-bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/02-bug_report.yaml @@ -35,8 +35,6 @@ body: label: Python Version description: What version of Python are you currently running? options: - - "3.10" - - "3.11" - "3.12" validations: required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85070d98e..a7bf43fe2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: NETBOX_CONFIGURATION: netbox.configuration_testing strategy: matrix: - python-version: ['3.10', '3.11', '3.12'] + python-version: ['3.12'] node-version: ['20.x'] services: redis: diff --git a/docs/development/getting-started.md b/docs/development/getting-started.md index 8add82c90..956226760 100644 --- a/docs/development/getting-started.md +++ b/docs/development/getting-started.md @@ -7,7 +7,7 @@ Getting started with NetBox development is pretty straightforward, and should fe * A Linux system or compatible environment * A PostgreSQL server, which can be installed locally [per the documentation](../installation/1-postgresql.md) * A Redis server, which can also be [installed locally](../installation/2-redis.md) -* Python 3.10 or later +* Python 3.12 or later ### 1. Fork the Repo diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index acf04dc2a..1b4633610 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -6,8 +6,8 @@ This section of the documentation discusses installing and configuring the NetBo Begin by installing all system packages required by NetBox and its dependencies. -!!! warning "Python 3.10 or later required" - NetBox supports Python 3.10, 3.11, and 3.12. +!!! warning "Python 3.12 or later required" + NetBox supports only Python 3.12 or later. ```no-highlight sudo apt install -y python3 python3-pip python3-venv python3-dev \ @@ -15,7 +15,7 @@ build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev \ libssl-dev zlib1g-dev ``` -Before continuing, check that your installed Python version is at least 3.10: +Before continuing, check that your installed Python version is at least 3.12: ```no-highlight python3 -V @@ -235,7 +235,7 @@ Once NetBox has been configured, we're ready to proceed with the actual installa sudo /opt/netbox/upgrade.sh ``` -Note that **Python 3.10 or later is required** for NetBox v4.0 and later releases. If the default Python installation on your server is set to a lesser version, pass the path to the supported installation as an environment variable named `PYTHON`. (Note that the environment variable must be passed _after_ the `sudo` command.) +Note that **Python 3.12 or later is required** for NetBox v4.5 and later releases. If the default Python installation on your server is set to a lesser version, pass the path to the supported installation as an environment variable named `PYTHON`. (Note that the environment variable must be passed _after_ the `sudo` command.) ```no-highlight sudo PYTHON=/usr/bin/python3.10 /opt/netbox/upgrade.sh diff --git a/docs/installation/4a-gunicorn.md b/docs/installation/4a-gunicorn.md index 3aca4ef0e..91bbcd0e5 100644 --- a/docs/installation/4a-gunicorn.md +++ b/docs/installation/4a-gunicorn.md @@ -60,6 +60,3 @@ You should see output similar to the following: If the NetBox service fails to start, issue the command `journalctl -eu netbox` to check for log messages that may indicate the problem. Once you've verified that the WSGI workers are up and running, move on to HTTP server setup. - -!!! note - There is a bug in the current stable release of gunicorn (v21.2.0) where automatic restarts of the worker processes can result in 502 errors under heavy load. (See [gunicorn bug #3038](https://github.com/benoitc/gunicorn/issues/3038) for more detail.) Users who encounter this issue may opt to downgrade to an earlier, unaffected release of gunicorn (`pip install gunicorn==20.1.0`). Note, however, that this earlier release does not officially support Python 3.11. diff --git a/docs/installation/index.md b/docs/installation/index.md index aefa39d17..08146ea2a 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -27,7 +27,7 @@ The following sections detail how to set up a new instance of NetBox: | Dependency | Supported Versions | |------------|--------------------| -| Python | 3.10, 3.11, 3.12 | +| Python | 3.12+ | | PostgreSQL | 14+ | | Redis | 4.0+ | diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index c9eed75e1..3bf453ca3 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -31,9 +31,9 @@ VERSION = RELEASE.full_version # Retained for backward compatibility BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Validate Python version -if sys.version_info < (3, 10): +if sys.version_info < (3, 12): raise RuntimeError( - f"NetBox requires Python 3.10 or later. (Currently installed: Python {platform.python_version()})" + f"NetBox requires Python 3.12 or later. (Currently installed: Python {platform.python_version()})" ) # diff --git a/netbox/utilities/datetime.py b/netbox/utilities/datetime.py index d54ea7c46..e3853cd99 100644 --- a/netbox/utilities/datetime.py +++ b/netbox/utilities/datetime.py @@ -20,9 +20,4 @@ def datetime_from_timestamp(value): """ Convert an ISO 8601 or RFC 3339 timestamp to a datetime object. """ - # Work around UTC issue for Python < 3.11; see - # https://docs.python.org/3/library/datetime.html#datetime.datetime.fromisoformat - # TODO: Remove this once Python 3.10 is no longer supported - if type(value) is str and value.endswith('Z'): - value = f'{value[:-1]}+00:00' return datetime.datetime.fromisoformat(value) diff --git a/pyproject.toml b/pyproject.toml index ca437c649..8d44db59f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ [project] name = "netbox" version = "4.4.0" -requires-python = ">=3.10" +requires-python = ">=3.12" description = "The premier source of truth powering network automation." readme = "README.md" license = "Apache-2.0" @@ -15,8 +15,6 @@ classifiers = [ "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", ] @@ -28,7 +26,7 @@ Issues = "https://github.com/netbox-community/netbox/issues" [tool.black] line-length = 120 -target_version = ['py310', 'py311', 'py312'] +target_version = ['py312'] skip-string-normalization = true [tool.isort] diff --git a/upgrade.sh b/upgrade.sh index 75a3ffc7f..ac4788def 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -4,7 +4,7 @@ # This script will invoke Python with the value of the PYTHON environment # variable (if set), or fall back to "python3". Note that NetBox v4.0+ requires -# Python 3.10 or later. +# Python 3.12 or later. # Parse arguments if [[ "$1" == "--readonly" ]]; then @@ -22,15 +22,15 @@ VIRTUALENV="$(pwd -P)/venv" PYTHON="${PYTHON:-python3}" # Validate the minimum required Python version -COMMAND="${PYTHON} -c 'import sys; exit(1 if sys.version_info < (3, 10) else 0)'" +COMMAND="${PYTHON} -c 'import sys; exit(1 if sys.version_info < (3, 12) else 0)'" PYTHON_VERSION=$(eval "${PYTHON} -V") eval $COMMAND || { echo "--------------------------------------------------------------------" echo "ERROR: Unsupported Python version: ${PYTHON_VERSION}. NetBox requires" - echo "Python 3.10 or later. To specify an alternate Python executable, set" + echo "Python 3.12 or later. To specify an alternate Python executable, set" echo "the PYTHON environment variable. For example:" echo "" - echo " sudo PYTHON=/usr/bin/python3.10 ./upgrade.sh" + echo " sudo PYTHON=/usr/bin/python3.12 ./upgrade.sh" echo "" echo "To show your current Python version: ${PYTHON} -V" echo "--------------------------------------------------------------------" From 530dad279aff7927227a272f23f9357f3f56ed93 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 5 Sep 2025 08:49:59 -0400 Subject: [PATCH 002/125] Closes #20095: Remove obsolete module core.models.contenttypes (#20250) --- netbox/core/models/contenttypes.py | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 netbox/core/models/contenttypes.py diff --git a/netbox/core/models/contenttypes.py b/netbox/core/models/contenttypes.py deleted file mode 100644 index 3d5c5e8b2..000000000 --- a/netbox/core/models/contenttypes.py +++ /dev/null @@ -1,3 +0,0 @@ -# TODO: Remove this module in NetBox v4.5 -# Provided for backward compatibility -from .object_types import * From ec9da881348406e270c829c67829dbe3ed0cf992 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 8 Sep 2025 15:36:12 -0400 Subject: [PATCH 003/125] Closes #19095: Introduce support for Python 3.13 & 3.14 --- .github/ISSUE_TEMPLATE/02-bug_report.yaml | 2 ++ .github/workflows/ci.yml | 2 +- docs/installation/3-netbox.md | 2 +- docs/installation/index.md | 2 +- docs/installation/upgrading.md | 5 +++-- docs/plugins/development/index.md | 8 ++++---- pyproject.toml | 4 +++- 7 files changed, 15 insertions(+), 10 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/02-bug_report.yaml b/.github/ISSUE_TEMPLATE/02-bug_report.yaml index 2a70c13b9..de92b3b8d 100644 --- a/.github/ISSUE_TEMPLATE/02-bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/02-bug_report.yaml @@ -36,6 +36,8 @@ body: description: What version of Python are you currently running? options: - "3.12" + - "3.13" + - "3.14" validations: required: true - type: textarea diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7bf43fe2..2fbc48d5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: NETBOX_CONFIGURATION: netbox.configuration_testing strategy: matrix: - python-version: ['3.12'] + python-version: ['3.12', '3.13', '3.14'] node-version: ['20.x'] services: redis: diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index 1b4633610..c192a3094 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -238,7 +238,7 @@ sudo /opt/netbox/upgrade.sh Note that **Python 3.12 or later is required** for NetBox v4.5 and later releases. If the default Python installation on your server is set to a lesser version, pass the path to the supported installation as an environment variable named `PYTHON`. (Note that the environment variable must be passed _after_ the `sudo` command.) ```no-highlight -sudo PYTHON=/usr/bin/python3.10 /opt/netbox/upgrade.sh +sudo PYTHON=/usr/bin/python3.12 /opt/netbox/upgrade.sh ``` !!! note diff --git a/docs/installation/index.md b/docs/installation/index.md index 08146ea2a..73bf1220c 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -27,7 +27,7 @@ The following sections detail how to set up a new instance of NetBox: | Dependency | Supported Versions | |------------|--------------------| -| Python | 3.12+ | +| Python | 3.12, 3.13, 3.14 | | PostgreSQL | 14+ | | Redis | 4.0+ | diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index cf0a16754..ce5282b04 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -19,7 +19,7 @@ NetBox requires the following dependencies: | Dependency | Supported Versions | |------------|--------------------| -| Python | 3.10, 3.11, 3.12 | +| Python | 3.12, 3.13, 3.14 | | PostgreSQL | 14+ | | Redis | 4.0+ | @@ -27,6 +27,7 @@ NetBox requires the following dependencies: | NetBox Version | Python min | Python max | PostgreSQL min | Redis min | Documentation | |:--------------:|:----------:|:----------:|:--------------:|:---------:|:-----------------------------------------------------------------------------------------:| +| 4.5 | 3.12 | 3.14 | 14 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.5.0/docs/installation/index.md) | | 4.4 | 3.10 | 3.12 | 14 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.4.0/docs/installation/index.md) | | 4.3 | 3.10 | 3.12 | 14 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.3.0/docs/installation/index.md) | | 4.2 | 3.10 | 3.12 | 13 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md) | @@ -130,7 +131,7 @@ sudo ./upgrade.sh If the default version of Python is not at least 3.10, you'll need to pass the path to a supported Python version as an environment variable when calling the upgrade script. For example: ```no-highlight - sudo PYTHON=/usr/bin/python3.10 ./upgrade.sh + sudo PYTHON=/usr/bin/python3.12 ./upgrade.sh ``` !!! note diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md index 56bde5e41..df8876391 100644 --- a/docs/plugins/development/index.md +++ b/docs/plugins/development/index.md @@ -173,12 +173,12 @@ classifiers=[ 'Intended Audience :: Developers', 'Natural Language :: English', "Programming Language :: Python :: 3 :: Only", - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', + 'Programming Language :: Python :: 3.14', ] -requires-python = ">=3.10.0" +requires-python = ">=3.12.0" ``` @@ -195,7 +195,7 @@ python3 -m venv ~/.virtualenvs/my_plugin You can make NetBox available within this environment by creating a path file pointing to its location. This will add NetBox to the Python path upon activation. (Be sure to adjust the command below to specify your actual virtual environment path, Python version, and NetBox installation.) ```shell -echo /opt/netbox/netbox > $VENV/lib/python3.10/site-packages/netbox.pth +echo /opt/netbox/netbox > $VENV/lib/python3.12/site-packages/netbox.pth ``` ## Development Installation diff --git a/pyproject.toml b/pyproject.toml index 8d44db59f..bd84e8bb6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,8 @@ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] [project.urls] @@ -26,7 +28,7 @@ Issues = "https://github.com/netbox-community/netbox/issues" [tool.black] line-length = 120 -target_version = ['py312'] +target_version = ['py312', 'py313', 'py314'] skip-string-normalization = true [tool.isort] From 550690186730285be1747baf9c7df451f3ac18ea Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 8 Sep 2025 15:41:06 -0400 Subject: [PATCH 004/125] Omit Python 3.14 from the testing matrix temporarily --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fbc48d5a..1cd9b9463 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: NETBOX_CONFIGURATION: netbox.configuration_testing strategy: matrix: - python-version: ['3.12', '3.13', '3.14'] + python-version: ['3.12', '3.13'] node-version: ['20.x'] services: redis: From c0e4d1c1e3a799046e9f1def844c075c4151822b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 10 Sep 2025 16:51:59 -0400 Subject: [PATCH 005/125] Closes #16137: Remove `is_staff` boolean from User model (#20306) * Closes #16137: Remove is_staff boolean from User model * Remove default is_staff value from UserManager.create_user() * Restore staff_only on MenuItem * Introduce IsSuperuser API permission to replace IsAdminUser * Update and improve RQ task API view tests * Remove is_staff attribute assignment from RemoteUserBackend --- .../administration/authentication/overview.md | 2 +- docs/configuration/remote-authentication.md | 16 --- docs/installation/6-ldap.md | 3 - docs/plugins/development/navigation.md | 19 +-- netbox/core/api/views.py | 4 +- netbox/core/tests/test_api.py | 109 ++++++++++++++---- netbox/core/tests/test_views.py | 8 +- netbox/core/views.py | 6 +- netbox/netbox/authentication/__init__.py | 20 +--- netbox/netbox/plugins/views.py | 4 +- netbox/netbox/settings.py | 2 - netbox/netbox/views/misc.py | 4 +- netbox/templates/account/profile.html | 4 - netbox/templates/inc/user_menu.html | 2 - netbox/templates/media_failure.html | 2 +- netbox/templates/users/user.html | 4 - netbox/users/api/serializers_/users.py | 2 +- netbox/users/filtersets.py | 2 +- netbox/users/forms/bulk_edit.py | 7 +- netbox/users/forms/bulk_import.py | 3 +- netbox/users/forms/filtersets.py | 9 +- netbox/users/forms/model_forms.py | 4 +- netbox/users/graphql/filters.py | 1 - netbox/users/graphql/types.py | 2 +- .../migrations/0012_user_remove_is_staff.py | 15 +++ netbox/users/models/users.py | 71 +++++++++++- netbox/users/tables.py | 7 +- netbox/users/tests/test_filtersets.py | 5 - netbox/utilities/api.py | 10 ++ netbox/utilities/templatetags/navigation.py | 2 +- 30 files changed, 220 insertions(+), 129 deletions(-) create mode 100644 netbox/users/migrations/0012_user_remove_is_staff.py diff --git a/docs/administration/authentication/overview.md b/docs/administration/authentication/overview.md index 19c86a4c0..6b805ee92 100644 --- a/docs/administration/authentication/overview.md +++ b/docs/administration/authentication/overview.md @@ -2,7 +2,7 @@ ## Local Authentication -Local user accounts and groups can be created in NetBox under the "Authentication" section in the "Admin" menu. This section is available only to users with the "staff" permission enabled. +Local user accounts and groups can be created in NetBox under the "Authentication" section in the "Admin" menu. At a minimum, each user account must have a username and password set. User accounts may also denote a first name, last name, and email address. [Permissions](../permissions.md) may also be assigned to individual users and/or groups as needed. diff --git a/docs/configuration/remote-authentication.md b/docs/configuration/remote-authentication.md index 5d5f1ee58..2dbb689c2 100644 --- a/docs/configuration/remote-authentication.md +++ b/docs/configuration/remote-authentication.md @@ -127,19 +127,3 @@ The list of groups that promote an remote User to Superuser on Login. If group i Default: `[]` (Empty list) The list of users that get promoted to Superuser on Login. If user isn't present in list on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` ) - ---- - -## REMOTE_AUTH_STAFF_GROUPS - -Default: `[]` (Empty list) - -The list of groups that promote an remote User to Staff on Login. If group isn't present on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` ) - ---- - -## REMOTE_AUTH_STAFF_USERS - -Default: `[]` (Empty list) - -The list of users that get promoted to Staff on Login. If user isn't present in list on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` ) diff --git a/docs/installation/6-ldap.md b/docs/installation/6-ldap.md index 7de9f116d..4d27f2f01 100644 --- a/docs/installation/6-ldap.md +++ b/docs/installation/6-ldap.md @@ -121,7 +121,6 @@ AUTH_LDAP_MIRROR_GROUPS = True # Define special user types using groups. Exercise great caution when assigning superuser status. AUTH_LDAP_USER_FLAGS_BY_GROUP = { "is_active": "cn=active,ou=groups,dc=example,dc=com", - "is_staff": "cn=staff,ou=groups,dc=example,dc=com", "is_superuser": "cn=superuser,ou=groups,dc=example,dc=com" } @@ -134,7 +133,6 @@ AUTH_LDAP_CACHE_TIMEOUT = 3600 ``` * `is_active` - All users must be mapped to at least this group to enable authentication. Without this, users cannot log in. -* `is_staff` - Users mapped to this group are enabled for access to the administration tools; this is the equivalent of checking the "staff status" box on a manually created user. This doesn't grant any specific permissions. * `is_superuser` - Users mapped to this group will be granted superuser status. Superusers are implicitly granted all permissions. !!! warning @@ -248,7 +246,6 @@ AUTH_LDAP_MIRROR_GROUPS = True # Define special user types using groups. Exercise great caution when assigning superuser status. AUTH_LDAP_USER_FLAGS_BY_GROUP = { "is_active": "cn=active,ou=groups,dc=example,dc=com", - "is_staff": "cn=staff,ou=groups,dc=example,dc=com", "is_superuser": "cn=superuser,ou=groups,dc=example,dc=com" } diff --git a/docs/plugins/development/navigation.md b/docs/plugins/development/navigation.md index b5e2694b4..bb855bcea 100644 --- a/docs/plugins/development/navigation.md +++ b/docs/plugins/development/navigation.md @@ -64,14 +64,17 @@ item1 = PluginMenuItem( A `PluginMenuItem` has the following attributes: -| Attribute | Required | Description | -|-----------------|----------|----------------------------------------------------------------------------------------------------------| -| `link` | Yes | Name of the URL path to which this menu item links | -| `link_text` | Yes | The text presented to the user | -| `permissions` | - | A list of permissions required to display this link | -| `auth_required` | - | Display only for authenticated users | -| `staff_only` | - | Display only for users who have `is_staff` set to true (any specified permissions will also be required) | -| `buttons` | - | An iterable of PluginMenuButton instances to include | +| Attribute | Required | Description | +|-----------------|----------|------------------------------------------------------| +| `link` | Yes | Name of the URL path to which this menu item links | +| `link_text` | Yes | The text presented to the user | +| `permissions` | - | A list of permissions required to display this link | +| `auth_required` | - | Display only for authenticated users | +| `staff_only` | - | Display only for superusers | +| `buttons` | - | An iterable of PluginMenuButton instances to include | + +!!! note "Changed in NetBox v4.5" + In releases prior to NetBox v4.5, `staff_only` restricted display of a menu item to only users with `is_staff` set to True. In NetBox v4.5, the `is_staff` flag was removed from the user model. Menu items with `staff_only` set to True are now displayed only for superusers. ## Menu Buttons diff --git a/netbox/core/api/views.py b/netbox/core/api/views.py index e9569a717..afa30ce56 100644 --- a/netbox/core/api/views.py +++ b/netbox/core/api/views.py @@ -9,7 +9,6 @@ from drf_spectacular.utils import extend_schema from rest_framework import viewsets from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied -from rest_framework.permissions import IsAdminUser from rest_framework.response import Response from rest_framework.routers import APIRootView from rest_framework.viewsets import ReadOnlyModelViewSet @@ -24,6 +23,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.metadata import ContentTypeMetadata from netbox.api.pagination import LimitOffsetListPagination from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet +from utilities.api import IsSuperuser from . import serializers @@ -99,7 +99,7 @@ class BaseRQViewSet(viewsets.ViewSet): """ Base class for RQ view sets. Provides a list() method. Subclasses must implement get_data(). """ - permission_classes = [IsAdminUser] + permission_classes = [IsSuperuser] serializer_class = None def get_data(self): diff --git a/netbox/core/tests/test_api.py b/netbox/core/tests/test_api.py index 4a285bdb4..159a8e5e1 100644 --- a/netbox/core/tests/test_api.py +++ b/netbox/core/tests/test_api.py @@ -134,10 +134,7 @@ class BackgroundTaskTestCase(TestCase): Create a user and token for API calls. """ # Create the test user and assign permissions - self.user = User.objects.create_user(username='testuser') - self.user.is_staff = True - self.user.is_active = True - self.user.save() + self.user = User.objects.create_user(username='testuser', is_active=True) self.token = Token.objects.create(user=self.user) self.header = {'HTTP_AUTHORIZATION': f'Token {self.token.key}'} @@ -150,13 +147,11 @@ class BackgroundTaskTestCase(TestCase): url = reverse('core-api:rqqueue-list') # Attempt to load view without permission - self.user.is_staff = False - self.user.save() response = self.client.get(url, **self.header) self.assertEqual(response.status_code, 403) # Load view with permission - self.user.is_staff = True + self.user.is_superuser = True self.user.save() response = self.client.get(url, **self.header) self.assertEqual(response.status_code, 200) @@ -165,7 +160,16 @@ class BackgroundTaskTestCase(TestCase): self.assertIn('low', str(response.content)) def test_background_queue(self): - response = self.client.get(reverse('core-api:rqqueue-detail', args=['default']), **self.header) + url = reverse('core-api:rqqueue-detail', args=['default']) + + # Attempt to load view without permission + response = self.client.get(url, **self.header) + self.assertEqual(response.status_code, 403) + + # Load view with permission + self.user.is_superuser = True + self.user.save() + response = self.client.get(url, **self.header) self.assertEqual(response.status_code, 200) self.assertIn('default', str(response.content)) self.assertIn('oldest_job_timestamp', str(response.content)) @@ -174,8 +178,16 @@ class BackgroundTaskTestCase(TestCase): def test_background_task_list(self): queue = get_queue('default') queue.enqueue(self.dummy_job_default) + url = reverse('core-api:rqtask-list') - response = self.client.get(reverse('core-api:rqtask-list'), **self.header) + # Attempt to load view without permission + response = self.client.get(url, **self.header) + self.assertEqual(response.status_code, 403) + + # Load view with permission + self.user.is_superuser = True + self.user.save() + response = self.client.get(url, **self.header) self.assertEqual(response.status_code, 200) self.assertIn('origin', str(response.content)) self.assertIn('core.tests.test_api.BackgroundTaskTestCase.dummy_job_default()', str(response.content)) @@ -183,8 +195,16 @@ class BackgroundTaskTestCase(TestCase): def test_background_task(self): queue = get_queue('default') job = queue.enqueue(self.dummy_job_default) + url = reverse('core-api:rqtask-detail', args=[job.id]) - response = self.client.get(reverse('core-api:rqtask-detail', args=[job.id]), **self.header) + # Attempt to load view without permission + response = self.client.get(url, **self.header) + self.assertEqual(response.status_code, 403) + + # Load view with permission + self.user.is_superuser = True + self.user.save() + response = self.client.get(url, **self.header) self.assertEqual(response.status_code, 200) self.assertIn(str(job.id), str(response.content)) self.assertIn('origin', str(response.content)) @@ -194,45 +214,65 @@ class BackgroundTaskTestCase(TestCase): def test_background_task_delete(self): queue = get_queue('default') job = queue.enqueue(self.dummy_job_default) + url = reverse('core-api:rqtask-delete', args=[job.id]) - response = self.client.post(reverse('core-api:rqtask-delete', args=[job.id]), **self.header) + # Attempt to load view without permission + response = self.client.get(url, **self.header) + self.assertEqual(response.status_code, 403) + + # Load view with permission + self.user.is_superuser = True + self.user.save() + response = self.client.post(url, **self.header) self.assertEqual(response.status_code, 200) self.assertFalse(RQ_Job.exists(job.id, connection=queue.connection)) queue = get_queue('default') self.assertNotIn(job.id, queue.job_ids) def test_background_task_requeue(self): - queue = get_queue('default') - # Enqueue & run a job that will fail + queue = get_queue('default') job = queue.enqueue(self.dummy_job_failing) worker = get_worker('default') with disable_logging(): worker.work(burst=True) self.assertTrue(job.is_failed) + url = reverse('core-api:rqtask-requeue', args=[job.id]) + + # Attempt to requeue the job without permission + response = self.client.post(url, **self.header) + self.assertEqual(response.status_code, 403) # Re-enqueue the failed job and check that its status has been reset - response = self.client.post(reverse('core-api:rqtask-requeue', args=[job.id]), **self.header) + self.user.is_superuser = True + self.user.save() + response = self.client.post(url, **self.header) self.assertEqual(response.status_code, 200) job = RQ_Job.fetch(job.id, queue.connection) self.assertFalse(job.is_failed) def test_background_task_enqueue(self): - queue = get_queue('default') - # Enqueue some jobs that each depends on its predecessor + queue = get_queue('default') job = previous_job = None for _ in range(0, 3): job = queue.enqueue(self.dummy_job_default, depends_on=previous_job) previous_job = job + url = reverse('core-api:rqtask-enqueue', args=[job.id]) # Check that the last job to be enqueued has a status of deferred self.assertIsNotNone(job) self.assertEqual(job.get_status(), JobStatus.DEFERRED) self.assertIsNone(job.enqueued_at) + # Attempt to force-enqueue the job without permission + response = self.client.post(url, **self.header) + self.assertEqual(response.status_code, 403) + # Force-enqueue the deferred job - response = self.client.post(reverse('core-api:rqtask-enqueue', args=[job.id]), **self.header) + self.user.is_superuser = True + self.user.save() + response = self.client.post(url, **self.header) self.assertEqual(response.status_code, 200) # Check that job's status is updated correctly @@ -242,19 +282,27 @@ class BackgroundTaskTestCase(TestCase): def test_background_task_stop(self): queue = get_queue('default') - worker = get_worker('default') job = queue.enqueue(self.dummy_job_default) worker.prepare_job_execution(job) - + url = reverse('core-api:rqtask-stop', args=[job.id]) self.assertEqual(job.get_status(), JobStatus.STARTED) - response = self.client.post(reverse('core-api:rqtask-stop', args=[job.id]), **self.header) + + # Attempt to stop the task without permission + response = self.client.post(url, **self.header) + self.assertEqual(response.status_code, 403) + + # Stop the task + self.user.is_superuser = True + self.user.save() + response = self.client.post(url, **self.header) self.assertEqual(response.status_code, 200) with disable_logging(): worker.monitor_work_horse(job, queue) # Sets the job as Failed and removes from Started started_job_registry = StartedJobRegistry(queue.name, connection=queue.connection) self.assertEqual(len(started_job_registry), 0) + # Verify that the task was cancelled canceled_job_registry = FailedJobRegistry(queue.name, connection=queue.connection) self.assertEqual(len(canceled_job_registry), 1) self.assertIn(job.id, canceled_job_registry) @@ -262,19 +310,34 @@ class BackgroundTaskTestCase(TestCase): def test_worker_list(self): worker1 = get_worker('default', name=uuid.uuid4().hex) worker1.register_birth() - worker2 = get_worker('high') worker2.register_birth() + url = reverse('core-api:rqworker-list') - response = self.client.get(reverse('core-api:rqworker-list'), **self.header) + # Attempt to fetch the worker list without permission + response = self.client.get(url, **self.header) + self.assertEqual(response.status_code, 403) + + # Fetch the worker list + self.user.is_superuser = True + self.user.save() + response = self.client.get(url, **self.header) self.assertEqual(response.status_code, 200) self.assertIn(str(worker1.name), str(response.content)) def test_worker(self): worker1 = get_worker('default', name=uuid.uuid4().hex) worker1.register_birth() + url = reverse('core-api:rqworker-detail', args=[worker1.name]) - response = self.client.get(reverse('core-api:rqworker-detail', args=[worker1.name]), **self.header) + # Attempt to fetch a worker without permission + response = self.client.get(url, **self.header) + self.assertEqual(response.status_code, 403) + + # Fetch the worker + self.user.is_superuser = True + self.user.save() + response = self.client.get(url, **self.header) self.assertEqual(response.status_code, 200) self.assertIn(str(worker1.name), str(response.content)) self.assertIn('birth_date', str(response.content)) diff --git a/netbox/core/tests/test_views.py b/netbox/core/tests/test_views.py index 1001243eb..2f0ef59e6 100644 --- a/netbox/core/tests/test_views.py +++ b/netbox/core/tests/test_views.py @@ -158,7 +158,7 @@ class BackgroundTaskTestCase(TestCase): def setUp(self): super().setUp() - self.user.is_staff = True + self.user.is_superuser = True self.user.is_active = True self.user.save() @@ -171,13 +171,13 @@ class BackgroundTaskTestCase(TestCase): url = reverse('core:background_queue_list') # Attempt to load view without permission - self.user.is_staff = False + self.user.is_superuser = False self.user.save() response = self.client.get(url) self.assertEqual(response.status_code, 403) # Load view with permission - self.user.is_staff = True + self.user.is_superuser = True self.user.save() response = self.client.get(url) self.assertEqual(response.status_code, 200) @@ -356,7 +356,7 @@ class SystemTestCase(TestCase): def setUp(self): super().setUp() - self.user.is_staff = True + self.user.is_superuser = True self.user.save() def test_system_view_default(self): diff --git a/netbox/core/views.py b/netbox/core/views.py index b18937308..aa3be7303 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -366,7 +366,7 @@ class ConfigRevisionRestoreView(ContentTypePermissionRequiredMixin, View): class BaseRQView(UserPassesTestMixin, View): def test_func(self): - return self.request.user.is_staff + return self.request.user.is_superuser class BackgroundQueueListView(TableMixin, BaseRQView): @@ -549,7 +549,7 @@ class WorkerView(BaseRQView): class SystemView(UserPassesTestMixin, View): def test_func(self): - return self.request.user.is_staff + return self.request.user.is_superuser def get(self, request): @@ -632,7 +632,7 @@ class BasePluginView(UserPassesTestMixin, View): CACHE_KEY_CATALOG_ERROR = 'plugins-catalog-error' def test_func(self): - return self.request.user.is_staff + return self.request.user.is_superuser def get_cached_plugins(self, request): catalog_plugins = {} diff --git a/netbox/netbox/authentication/__init__.py b/netbox/netbox/authentication/__init__.py index 25f9b902c..8596c59ab 100644 --- a/netbox/netbox/authentication/__init__.py +++ b/netbox/netbox/authentication/__init__.py @@ -184,14 +184,13 @@ class RemoteUserBackend(_RemoteUserBackend): else: user.groups.clear() logger.debug(f"Stripping user {user} from Groups") + + # Evaluate superuser status user.is_superuser = self._is_superuser(user) logger.debug(f"User {user} is Superuser: {user.is_superuser}") logger.debug( f"User {user} should be Superuser: {self._is_superuser(user)}") - user.is_staff = self._is_staff(user) - logger.debug(f"User {user} is Staff: {user.is_staff}") - logger.debug(f"User {user} should be Staff: {self._is_staff(user)}") user.save() return user @@ -251,19 +250,8 @@ class RemoteUserBackend(_RemoteUserBackend): return bool(result) def _is_staff(self, user): - logger = logging.getLogger('netbox.auth.RemoteUserBackend') - staff_groups = settings.REMOTE_AUTH_STAFF_GROUPS - logger.debug(f"Superuser Groups: {staff_groups}") - staff_users = settings.REMOTE_AUTH_STAFF_USERS - logger.debug(f"Staff Users :{staff_users}") - user_groups = set() - for g in user.groups.all(): - user_groups.add(g.name) - logger.debug(f"User {user.username} is in Groups:{user_groups}") - result = user.username in staff_users or ( - set(user_groups) & set(staff_groups)) - logger.debug(f"User {user.username} in Staff Users :{result}") - return bool(result) + # Retain for pre-v4.5 compatibility + return user.is_superuser def configure_user(self, request, user): logger = logging.getLogger('netbox.auth.RemoteUserBackend') diff --git a/netbox/netbox/plugins/views.py b/netbox/netbox/plugins/views.py index feee78e82..f413b8032 100644 --- a/netbox/netbox/plugins/views.py +++ b/netbox/netbox/plugins/views.py @@ -3,12 +3,12 @@ from collections import OrderedDict from django.apps import apps from django.urls.exceptions import NoReverseMatch from drf_spectacular.utils import extend_schema -from rest_framework import permissions from rest_framework.response import Response from rest_framework.reverse import reverse from rest_framework.views import APIView from netbox.registry import registry +from utilities.api import IsSuperuser @extend_schema(exclude=True) @@ -16,7 +16,7 @@ class InstalledPluginsAPIView(APIView): """ API view for listing all installed plugins """ - permission_classes = [permissions.IsAdminUser] + permission_classes = [IsSuperuser] _ignore_model_permissions = True schema = None diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 3bf453ca3..a0a6225c3 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -161,8 +161,6 @@ REMOTE_AUTH_SUPERUSERS = getattr(configuration, 'REMOTE_AUTH_SUPERUSERS', []) REMOTE_AUTH_USER_EMAIL = getattr(configuration, 'REMOTE_AUTH_USER_EMAIL', 'HTTP_REMOTE_USER_EMAIL') REMOTE_AUTH_USER_FIRST_NAME = getattr(configuration, 'REMOTE_AUTH_USER_FIRST_NAME', 'HTTP_REMOTE_USER_FIRST_NAME') REMOTE_AUTH_USER_LAST_NAME = getattr(configuration, 'REMOTE_AUTH_USER_LAST_NAME', 'HTTP_REMOTE_USER_LAST_NAME') -REMOTE_AUTH_STAFF_GROUPS = getattr(configuration, 'REMOTE_AUTH_STAFF_GROUPS', []) -REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', []) # Required by extras/migrations/0109_script_models.py REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300) diff --git a/netbox/netbox/views/misc.py b/netbox/netbox/views/misc.py index ab995db66..4537f14c9 100644 --- a/netbox/netbox/views/misc.py +++ b/netbox/netbox/views/misc.py @@ -47,9 +47,9 @@ class HomeView(ConditionalLoginRequiredMixin, View): )) dashboard = get_default_dashboard(config=DEFAULT_DASHBOARD).get_layout() - # Check whether a new release is available. (Only for staff/superusers.) + # Check whether a new release is available. (Only for superusers.) new_release = None - if request.user.is_staff or request.user.is_superuser: + if request.user.is_superuser: latest_release = cache.get('latest_release') if latest_release: release_version, release_url = latest_release diff --git a/netbox/templates/account/profile.html b/netbox/templates/account/profile.html index 442cce9ba..8ef0db6e8 100644 --- a/netbox/templates/account/profile.html +++ b/netbox/templates/account/profile.html @@ -39,10 +39,6 @@ {% trans "Superuser" %} {% checkmark request.user.is_superuser %} - - {% trans "Staff" %} - {% checkmark request.user.is_staff %} - diff --git a/netbox/templates/inc/user_menu.html b/netbox/templates/inc/user_menu.html index 4cca0b57e..50173ea56 100644 --- a/netbox/templates/inc/user_menu.html +++ b/netbox/templates/inc/user_menu.html @@ -27,8 +27,6 @@
{% if request.user.is_superuser %} {% trans "Admin" %} - {% elif request.user.is_staff %} - {% trans "Staff" %} {% else %} {% trans "User" %} {% endif %} diff --git a/netbox/templates/media_failure.html b/netbox/templates/media_failure.html index 8f357aea1..50ee1b63f 100644 --- a/netbox/templates/media_failure.html +++ b/netbox/templates/media_failure.html @@ -37,7 +37,7 @@ path. Refer to the installation documentation for further guidance. {% endblocktrans %}
    - {% if request.user.is_staff or request.user.is_superuser %} + {% if request.user.is_superuser %}
  • STATIC_ROOT: {{ settings.STATIC_ROOT }}
  • {% endif %}
  • STATIC_URL: {{ settings.STATIC_URL }}
  • diff --git a/netbox/templates/users/user.html b/netbox/templates/users/user.html index 84e4cac68..90a0bb084 100644 --- a/netbox/templates/users/user.html +++ b/netbox/templates/users/user.html @@ -35,10 +35,6 @@ {% trans "Active" %} {% checkmark object.is_active %} - - {% trans "Staff" %} - {% checkmark object.is_staff %} - {% trans "Superuser" %} {% checkmark object.is_superuser %} diff --git a/netbox/users/api/serializers_/users.py b/netbox/users/api/serializers_/users.py index 7b4dc5a07..da2aa1820 100644 --- a/netbox/users/api/serializers_/users.py +++ b/netbox/users/api/serializers_/users.py @@ -52,7 +52,7 @@ class UserSerializer(ValidatedModelSerializer): model = User fields = ( 'id', 'url', 'display_url', 'display', 'username', 'password', 'first_name', 'last_name', 'email', - 'is_staff', 'is_active', 'date_joined', 'last_login', 'groups', 'permissions', + 'is_active', 'date_joined', 'last_login', 'groups', 'permissions', ) brief_fields = ('id', 'url', 'display', 'username') extra_kwargs = { diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index af769e437..4e1510410 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -81,7 +81,7 @@ class UserFilterSet(BaseFilterSet): class Meta: model = User fields = ( - 'id', 'username', 'first_name', 'last_name', 'email', 'date_joined', 'last_login', 'is_staff', 'is_active', + 'id', 'username', 'first_name', 'last_name', 'email', 'date_joined', 'last_login', 'is_active', 'is_superuser', ) diff --git a/netbox/users/forms/bulk_edit.py b/netbox/users/forms/bulk_edit.py index 7c50f1ed1..bca417b3d 100644 --- a/netbox/users/forms/bulk_edit.py +++ b/netbox/users/forms/bulk_edit.py @@ -37,11 +37,6 @@ class UserBulkEditForm(BulkEditForm): widget=BulkEditNullBooleanSelect, label=_('Active') ) - is_staff = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect, - label=_('Staff status') - ) is_superuser = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect, @@ -50,7 +45,7 @@ class UserBulkEditForm(BulkEditForm): model = User fieldsets = ( - FieldSet('first_name', 'last_name', 'is_active', 'is_staff', 'is_superuser'), + FieldSet('first_name', 'last_name', 'is_active', 'is_superuser'), ) nullable_fields = ('first_name', 'last_name') diff --git a/netbox/users/forms/bulk_import.py b/netbox/users/forms/bulk_import.py index 748338a64..f478dedbf 100644 --- a/netbox/users/forms/bulk_import.py +++ b/netbox/users/forms/bulk_import.py @@ -23,8 +23,7 @@ class UserImportForm(CSVModelForm): class Meta: model = User fields = ( - 'username', 'first_name', 'last_name', 'email', 'password', 'is_staff', - 'is_active', 'is_superuser' + 'username', 'first_name', 'last_name', 'email', 'password', 'is_active', 'is_superuser' ) def save(self, *args, **kwargs): diff --git a/netbox/users/forms/filtersets.py b/netbox/users/forms/filtersets.py index 750b77ba4..61e55949c 100644 --- a/netbox/users/forms/filtersets.py +++ b/netbox/users/forms/filtersets.py @@ -29,7 +29,7 @@ class UserFilterForm(NetBoxModelFilterSetForm): fieldsets = ( FieldSet('q', 'filter_id',), FieldSet('group_id', name=_('Group')), - FieldSet('is_active', 'is_staff', 'is_superuser', name=_('Status')), + FieldSet('is_active', 'is_superuser', name=_('Status')), ) group_id = DynamicModelMultipleChoiceField( queryset=Group.objects.all(), @@ -43,13 +43,6 @@ class UserFilterForm(NetBoxModelFilterSetForm): ), label=_('Is Active'), ) - is_staff = forms.NullBooleanField( - required=False, - widget=forms.Select( - choices=BOOLEAN_WITH_BLANK_CHOICES - ), - label=_('Is Staff'), - ) is_superuser = forms.NullBooleanField( required=False, widget=forms.Select( diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index 505104c03..127e43e5a 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -192,7 +192,7 @@ class UserForm(forms.ModelForm): fieldsets = ( FieldSet('username', 'password', 'confirm_password', 'first_name', 'last_name', 'email', name=_('User')), FieldSet('groups', name=_('Groups')), - FieldSet('is_active', 'is_staff', 'is_superuser', name=_('Status')), + FieldSet('is_active', 'is_superuser', name=_('Status')), FieldSet('object_permissions', name=_('Permissions')), ) @@ -200,7 +200,7 @@ class UserForm(forms.ModelForm): model = User fields = [ 'username', 'first_name', 'last_name', 'email', 'groups', 'object_permissions', - 'is_active', 'is_staff', 'is_superuser', + 'is_active', 'is_superuser', ] def __init__(self, *args, **kwargs): diff --git a/netbox/users/graphql/filters.py b/netbox/users/graphql/filters.py index 60a80181b..07f28bb88 100644 --- a/netbox/users/graphql/filters.py +++ b/netbox/users/graphql/filters.py @@ -27,7 +27,6 @@ class UserFilter(BaseObjectTypeFilterMixin): last_name: FilterLookup[str] | None = strawberry_django.filter_field() email: FilterLookup[str] | None = strawberry_django.filter_field() is_superuser: FilterLookup[bool] | None = strawberry_django.filter_field() - is_staff: FilterLookup[bool] | None = strawberry_django.filter_field() is_active: FilterLookup[bool] | None = strawberry_django.filter_field() date_joined: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() last_login: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() diff --git a/netbox/users/graphql/types.py b/netbox/users/graphql/types.py index c5b338553..5231194e5 100644 --- a/netbox/users/graphql/types.py +++ b/netbox/users/graphql/types.py @@ -25,7 +25,7 @@ class GroupType(BaseObjectType): @strawberry_django.type( User, fields=[ - 'id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'date_joined', 'groups', + 'id', 'username', 'first_name', 'last_name', 'email', 'is_active', 'date_joined', 'groups', ], filters=UserFilter, pagination=True diff --git a/netbox/users/migrations/0012_user_remove_is_staff.py b/netbox/users/migrations/0012_user_remove_is_staff.py new file mode 100644 index 000000000..04f90107d --- /dev/null +++ b/netbox/users/migrations/0012_user_remove_is_staff.py @@ -0,0 +1,15 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0011_concrete_objecttype'), + ] + + operations = [ + migrations.RemoveField( + model_name='user', + name='is_staff', + ), + ] diff --git a/netbox/users/models/users.py b/netbox/users/models/users.py index b5308f8d5..4a7e917b3 100644 --- a/netbox/users/models/users.py +++ b/netbox/users/models/users.py @@ -1,12 +1,16 @@ +from django.contrib.auth.base_user import AbstractBaseUser from django.contrib.auth.models import ( - AbstractUser, GroupManager as DjangoGroupManager, Permission, + PermissionsMixin, UserManager as DjangoUserManager ) +from django.contrib.auth.validators import UnicodeUsernameValidator from django.core.exceptions import ValidationError +from django.core.mail import send_mail from django.db import models from django.urls import reverse +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from utilities.querysets import RestrictedQuerySet @@ -68,10 +72,48 @@ class Group(models.Model): class UserManager(DjangoUserManager.from_queryset(RestrictedQuerySet)): - pass + + def create_user(self, username, email=None, password=None, **extra_fields): + extra_fields.setdefault("is_superuser", False) + return self._create_user(username, email, password, **extra_fields) -class User(AbstractUser): +class User(AbstractBaseUser, PermissionsMixin): + username = models.CharField( + _("username"), + max_length=150, + unique=True, + help_text=_("Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only."), + validators=[UnicodeUsernameValidator()], + error_messages={ + "unique": _("A user with that username already exists."), + }, + ) + first_name = models.CharField( + _("first name"), + max_length=150, + blank=True, + ) + last_name = models.CharField( + _("last name"), + max_length=150, + blank=True, + ) + email = models.EmailField( + _("email address"), + blank=True, + ) + is_active = models.BooleanField( + _("active"), + default=True, + help_text=_( + "Designates whether this user should be treated as active. Unselect this instead of deleting accounts." + ), + ) + date_joined = models.DateTimeField( + _("date joined"), + default=timezone.now, + ) groups = models.ManyToManyField( to='users.Group', verbose_name=_('groups'), @@ -87,6 +129,11 @@ class User(AbstractUser): objects = UserManager() + # Ensure compatibility with Django's stock User model + EMAIL_FIELD = "email" + USERNAME_FIELD = "username" + REQUIRED_FIELDS = ["email"] + class Meta: ordering = ('username',) verbose_name = _('user') @@ -98,7 +145,25 @@ class User(AbstractUser): def clean(self): super().clean() + # Normalize email address + self.email = self.__class__.objects.normalize_email(self.email) + # Check for any existing Users with names that differ only in case model = self._meta.model if model.objects.exclude(pk=self.pk).filter(username__iexact=self.username).exists(): raise ValidationError(_("A user with this username already exists.")) + + def get_full_name(self): + """ + Return the first_name plus the last_name, with a space in between. + """ + full_name = "%s %s" % (self.first_name, self.last_name) + return full_name.strip() + + def get_short_name(self): + """Return the short name for the user.""" + return self.first_name + + def email_user(self, subject, message, from_email=None, **kwargs): + """Send an email to this user.""" + send_mail(subject, message, from_email, [self.email], **kwargs) diff --git a/netbox/users/tables.py b/netbox/users/tables.py index fc2f9702d..40cbeca47 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -38,9 +38,6 @@ class UserTable(NetBoxTable): is_active = columns.BooleanColumn( verbose_name=_('Is Active'), ) - is_staff = columns.BooleanColumn( - verbose_name=_('Is Staff'), - ) is_superuser = columns.BooleanColumn( verbose_name=_('Is Superuser'), ) @@ -51,8 +48,8 @@ class UserTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = User fields = ( - 'pk', 'id', 'username', 'first_name', 'last_name', 'email', 'groups', 'is_active', 'is_staff', - 'is_superuser', 'last_login', + 'pk', 'id', 'username', 'first_name', 'last_name', 'email', 'groups', 'is_active', 'is_superuser', + 'last_login', ) default_columns = ('pk', 'username', 'first_name', 'last_name', 'email', 'is_active') diff --git a/netbox/users/tests/test_filtersets.py b/netbox/users/tests/test_filtersets.py index 8b683b346..e15df0d18 100644 --- a/netbox/users/tests/test_filtersets.py +++ b/netbox/users/tests/test_filtersets.py @@ -30,7 +30,6 @@ class UserTestCase(TestCase, BaseFilterSetTests): first_name='Hank', last_name='Hill', email='hank@stricklandpropane.com', - is_staff=True, is_superuser=True ), User( @@ -98,10 +97,6 @@ class UserTestCase(TestCase, BaseFilterSetTests): params = {'is_active': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - def test_is_staff(self): - params = {'is_staff': True} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - def test_is_superuser(self): params = {'is_superuser': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 0aec1daef..6e7ce5ffb 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -6,6 +6,7 @@ from django.db.models.fields.related import ManyToOneRel, RelatedField from django.urls import reverse from django.utils.module_loading import import_string from django.utils.translation import gettext_lazy as _ +from rest_framework.permissions import BasePermission from rest_framework.serializers import Serializer from rest_framework.views import get_view_name as drf_get_view_name @@ -16,6 +17,7 @@ from .query import count_related, dict_to_filter_params from .string import title __all__ = ( + 'IsSuperuser', 'get_annotations_for_serializer', 'get_graphql_type_for_model', 'get_prefetches_for_serializer', @@ -27,6 +29,14 @@ __all__ = ( ) +class IsSuperuser(BasePermission): + """ + Allows access only to superusers. + """ + def has_permission(self, request, view): + return bool(request.user and request.user.is_superuser) + + def get_serializer_for_model(model, prefix=''): """ Return the appropriate REST API serializer for the given model. diff --git a/netbox/utilities/templatetags/navigation.py b/netbox/utilities/templatetags/navigation.py index 176996aa1..2c7ce18dd 100644 --- a/netbox/utilities/templatetags/navigation.py +++ b/netbox/utilities/templatetags/navigation.py @@ -30,7 +30,7 @@ def nav(context): continue if not user.has_perms(item.permissions): continue - if item.staff_only and not user.is_staff: + if item.staff_only and not user.is_superuser: continue buttons = [ button for button in item.buttons if user.has_perms(button.permissions) From 21ba27fb39b3f2f13c21062611ab6747ca8f4adf Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 11 Sep 2025 11:30:15 -0400 Subject: [PATCH 006/125] Closes #20096: Remove legacy load_yaml() & load_json() methods from BaseScript --- docs/customization/custom-scripts.md | 11 -------- netbox/extras/scripts.py | 39 +--------------------------- netbox/extras/tests/test_scripts.py | 32 ----------------------- 3 files changed, 1 insertion(+), 81 deletions(-) diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index df9437634..b5c407fc9 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -131,17 +131,6 @@ self.log_info(f"Running as user {username} (IP: {ip_address})...") For a complete list of available request parameters, please see the [Django documentation](https://docs.djangoproject.com/en/stable/ref/request-response/). -## Reading Data from Files - -The Script class provides two convenience methods for reading data from files: - -* `load_yaml` -* `load_json` - -These two methods will load data in YAML or JSON format, respectively, from files within the local path (i.e. `SCRIPTS_ROOT`). - -**Note:** These convenience methods are deprecated and will be removed in NetBox v4.4. These only work if running scripts within the local path, they will not work if using a storage other than ScriptFileSystemStorage. - ## Logging The Script object provides a set of convenient functions for recording messages at different severity levels: diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index a14eba556..4a307628c 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -1,12 +1,9 @@ import inspect -import json import logging import os import re -import yaml from django import forms -from django.conf import settings from django.core.files.storage import storages from django.core.validators import RegexValidator from django.utils import timezone @@ -488,7 +485,7 @@ class BaseScript: if self.fieldsets: fieldsets.extend(self.fieldsets) else: - fields = list(name for name, _ in self._get_vars().items()) + fields = list(name for name, __ in self._get_vars().items()) fieldsets.append((_('Script Data'), fields)) # Append the default fieldset if defined in the Meta class @@ -580,40 +577,6 @@ class BaseScript: self._log(message, obj, level=LogLevelChoices.LOG_FAILURE) self.failed = True - # - # Convenience functions - # - - def load_yaml(self, filename): - """ - Return data from a YAML file - """ - # TODO: DEPRECATED: Remove this method in v4.5 - self._log( - _("load_yaml is deprecated and will be removed in v4.5"), - level=LogLevelChoices.LOG_WARNING - ) - file_path = os.path.join(settings.SCRIPTS_ROOT, filename) - with open(file_path, 'r') as datafile: - data = yaml.load(datafile, Loader=yaml.SafeLoader) - - return data - - def load_json(self, filename): - """ - Return data from a JSON file - """ - # TODO: DEPRECATED: Remove this method in v4.5 - self._log( - _("load_json is deprecated and will be removed in v4.5"), - level=LogLevelChoices.LOG_WARNING - ) - file_path = os.path.join(settings.SCRIPTS_ROOT, filename) - with open(file_path, 'r') as datafile: - data = json.load(datafile) - - return data - # # Legacy Report functionality # diff --git a/netbox/extras/tests/test_scripts.py b/netbox/extras/tests/test_scripts.py index 4f5d0187a..5db15bd41 100644 --- a/netbox/extras/tests/test_scripts.py +++ b/netbox/extras/tests/test_scripts.py @@ -1,5 +1,3 @@ -import logging -import tempfile from datetime import date, datetime, timezone from decimal import Decimal @@ -9,7 +7,6 @@ from netaddr import IPAddress, IPNetwork from dcim.models import DeviceRole from extras.scripts import * -from utilities.testing import disable_logging CHOICES = ( ('ff0000', 'Red'), @@ -35,35 +32,6 @@ JSON_DATA = """ """ -class ScriptTest(TestCase): - - def test_load_yaml(self): - datafile = tempfile.NamedTemporaryFile() - datafile.write(bytes(YAML_DATA, 'UTF-8')) - datafile.seek(0) - - with disable_logging(level=logging.WARNING): - data = Script().load_yaml(datafile.name) - self.assertEqual(data, { - 'Foo': 123, - 'Bar': 456, - 'Baz': ['A', 'B', 'C'], - }) - - def test_load_json(self): - datafile = tempfile.NamedTemporaryFile() - datafile.write(bytes(JSON_DATA, 'UTF-8')) - datafile.seek(0) - - with disable_logging(level=logging.WARNING): - data = Script().load_json(datafile.name) - self.assertEqual(data, { - 'Foo': 123, - 'Bar': 456, - 'Baz': ['A', 'B', 'C'], - }) - - class ScriptVariablesTest(TestCase): def test_stringvar(self): From cfcea7c9418649491c5540adf650621b04f75091 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 11 Sep 2025 16:09:49 -0400 Subject: [PATCH 007/125] Closes #19898: Remove legacy /api/extras/object-types/ endpoint (#20324) Closes #19898: Remove legacy /api/extras/object-types/ endpoint --- netbox/core/tests/test_api.py | 4 ++-- netbox/extras/api/urls.py | 5 ----- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/netbox/core/tests/test_api.py b/netbox/core/tests/test_api.py index 159a8e5e1..4d612e157 100644 --- a/netbox/core/tests/test_api.py +++ b/netbox/core/tests/test_api.py @@ -107,14 +107,14 @@ class ObjectTypeTest(APITestCase): def test_list_objects(self): object_type_count = ObjectType.objects.count() - response = self.client.get(reverse('extras-api:objecttype-list'), **self.header) + response = self.client.get(reverse('core-api:objecttype-list'), **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(response.data['count'], object_type_count) def test_get_object(self): object_type = ObjectType.objects.first() - url = reverse('extras-api:objecttype-detail', kwargs={'pk': object_type.pk}) + url = reverse('core-api:objecttype-detail', kwargs={'pk': object_type.pk}) self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK) diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 3757157b4..52cf28b16 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -1,10 +1,8 @@ from django.urls import include, path -from core.api.views import ObjectTypeViewSet from netbox.api.routers import NetBoxRouter from . import views - router = NetBoxRouter() router.APIRootView = views.ExtrasRootView @@ -29,9 +27,6 @@ router.register('config-context-profiles', views.ConfigContextProfileViewSet) router.register('config-templates', views.ConfigTemplateViewSet) router.register('scripts', views.ScriptViewSet, basename='script') -# TODO: Remove in NetBox v4.5 -router.register('object-types', ObjectTypeViewSet) - app_name = 'extras-api' urlpatterns = [ path('dashboard/', views.DashboardView.as_view(), name='dashboard'), From b9567208d48a3b2fca5393370257e66678b39952 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 12 Sep 2025 12:54:54 -0400 Subject: [PATCH 008/125] Closes #20088: Remove `model` from webhook context (replaced by `object_type`) (#20325) --- netbox/extras/tests/test_event_rules.py | 2 +- netbox/extras/webhooks.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/netbox/extras/tests/test_event_rules.py b/netbox/extras/tests/test_event_rules.py index 0c9c25de3..082550346 100644 --- a/netbox/extras/tests/test_event_rules.py +++ b/netbox/extras/tests/test_event_rules.py @@ -363,7 +363,7 @@ class EventRuleTest(APITestCase): body = json.loads(request.body) self.assertEqual(body['event'], 'created') self.assertEqual(body['timestamp'], job.kwargs['timestamp']) - self.assertEqual(body['model'], 'site') + 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') diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index a68f219bd..1492eb6ea 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -52,7 +52,6 @@ def send_webhook(event_rule, object_type, event_type, data, timestamp, username, 'event': WEBHOOK_EVENT_TYPES.get(event_type, event_type), 'timestamp': timestamp, 'object_type': '.'.join(object_type.natural_key()), - 'model': object_type.model, 'username': username, 'request_id': request.id if request else None, 'data': data, @@ -100,7 +99,7 @@ def send_webhook(event_rule, object_type, event_type, data, timestamp, username, 'data': body.encode('utf8'), } logger.info( - f"Sending {params['method']} request to {params['url']} ({context['model']} {context['event']})" + f"Sending {params['method']} request to {params['url']} ({context['object_type']} {context['event']})" ) logger.debug(params) try: From b4eaeead13e21c09f33f92d2f5edd255c593cdbd Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Mon, 15 Sep 2025 20:36:51 +0200 Subject: [PATCH 009/125] Fixes #20342: Override create_superuser to drop is_staff (#20351) * fix(users): Override create_superuser to drop is_staff Override `UserManager.create_superuser()` to strip `is_staff` from `extra_fields` and enforce `is_superuser=True`, fixing the `TypeError` during `createsuperuser` with the custom `User` model. Fixes #20342 * Set alters_data=True on manager methods --------- Co-authored-by: Jeremy Stretch --- netbox/users/models/users.py | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/netbox/users/models/users.py b/netbox/users/models/users.py index 4a7e917b3..cbfb46330 100644 --- a/netbox/users/models/users.py +++ b/netbox/users/models/users.py @@ -3,7 +3,7 @@ from django.contrib.auth.models import ( GroupManager as DjangoGroupManager, Permission, PermissionsMixin, - UserManager as DjangoUserManager + UserManager as DjangoUserManager, ) from django.contrib.auth.validators import UnicodeUsernameValidator from django.core.exceptions import ValidationError @@ -74,9 +74,37 @@ class Group(models.Model): class UserManager(DjangoUserManager.from_queryset(RestrictedQuerySet)): def create_user(self, username, email=None, password=None, **extra_fields): - extra_fields.setdefault("is_superuser", False) + extra_fields.setdefault('is_superuser', False) return self._create_user(username, email, password, **extra_fields) + create_user.alters_data = True + + async def acreate_user(self, username, email=None, password=None, **extra_fields): + extra_fields.setdefault('is_superuser', False) + return await self._acreate_user(username, email, password, **extra_fields) + + acreate_user.alters_data = True + + def create_superuser(self, username, email=None, password=None, **extra_fields): + extra_fields.setdefault('is_superuser', True) + + if extra_fields.get('is_superuser') is not True: + raise ValueError('Superuser must have is_superuser=True.') + + return self._create_user(username, email, password, **extra_fields) + + create_superuser.alters_data = True + + async def acreate_superuser(self, username, email=None, password=None, **extra_fields): + extra_fields.setdefault('is_superuser', True) + + if extra_fields.get('is_superuser') is not True: + raise ValueError('Superuser must have is_superuser=True.') + + return await self._acreate_user(username, email, password, **extra_fields) + + acreate_superuser.alters_data = True + class User(AbstractBaseUser, PermissionsMixin): username = models.CharField( From f0ae0da1c74af1c530bce253fa00fe7b6506db96 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 18 Sep 2025 15:09:07 -0400 Subject: [PATCH 010/125] Update OpenAPI schema --- contrib/openapi.json | 531 +------------------------------------------ 1 file changed, 1 insertion(+), 530 deletions(-) diff --git a/contrib/openapi.json b/contrib/openapi.json index ddbdff987..e241789e6 100644 --- a/contrib/openapi.json +++ b/contrib/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "NetBox REST API", - "version": "4.4.0", + "version": "4.4.1", "license": { "name": "Apache v2 License" } @@ -121570,513 +121570,6 @@ } } }, - "/api/extras/object-types/": { - "get": { - "operationId": "extras_object_types_list", - "description": "Read-only list of ObjectTypes.", - "parameters": [ - { - "in": "query", - "name": "app_label", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "app_label__empty", - "schema": { - "type": "boolean" - } - }, - { - "in": "query", - "name": "app_label__ic", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "app_label__ie", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "app_label__iew", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "app_label__iregex", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "app_label__isw", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "app_label__n", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "app_label__nic", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "app_label__nie", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "app_label__niew", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "app_label__nisw", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "app_label__regex", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "features", - "schema": { - "type": "string" - } - }, - { - "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" - }, - { - "name": "limit", - "required": false, - "in": "query", - "description": "Number of results to return per page.", - "schema": { - "type": "integer" - } - }, - { - "in": "query", - "name": "model", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "model__empty", - "schema": { - "type": "boolean" - } - }, - { - "in": "query", - "name": "model__ic", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "model__ie", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "model__iew", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "model__iregex", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "model__isw", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "model__n", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "model__nic", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "model__nie", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "model__niew", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "model__nisw", - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "model__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" - } - }, - { - "name": "ordering", - "required": false, - "in": "query", - "description": "Which field to use when ordering the results.", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "public", - "schema": { - "type": "boolean" - } - }, - { - "in": "query", - "name": "q", - "schema": { - "type": "string" - }, - "description": "Search" - } - ], - "tags": [ - "extras" - ], - "security": [ - { - "cookieAuth": [] - }, - { - "tokenAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaginatedObjectTypeList" - } - } - }, - "description": "" - } - } - } - }, - "/api/extras/object-types/{id}/": { - "get": { - "operationId": "extras_object_types_retrieve", - "description": "Read-only list of ObjectTypes.", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "description": "A unique integer value identifying this object type.", - "required": true - } - ], - "tags": [ - "extras" - ], - "security": [ - { - "cookieAuth": [] - }, - { - "tokenAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ObjectType" - } - } - }, - "description": "" - } - } - } - }, "/api/extras/saved-filters/": { "get": { "operationId": "extras_saved_filters_list", @@ -167891,13 +167384,6 @@ "type": "boolean" } }, - { - "in": "query", - "name": "is_staff", - "schema": { - "type": "boolean" - } - }, { "in": "query", "name": "is_superuser", @@ -228725,11 +228211,6 @@ "title": "Email address", "maxLength": 254 }, - "is_staff": { - "type": "boolean", - "title": "Staff status", - "description": "Designates whether the user can log into this admin site." - }, "is_active": { "type": "boolean", "title": "Active", @@ -245644,11 +245125,6 @@ "title": "Email address", "maxLength": 254 }, - "is_staff": { - "type": "boolean", - "title": "Staff status", - "description": "Designates whether the user can log into this admin site." - }, "is_active": { "type": "boolean", "title": "Active", @@ -245715,11 +245191,6 @@ "title": "Email address", "maxLength": 254 }, - "is_staff": { - "type": "boolean", - "title": "Staff status", - "description": "Designates whether the user can log into this admin site." - }, "is_active": { "type": "boolean", "title": "Active", From 12818f178694adc3c1d6ef7daef72ab6bdba11c8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 19 Sep 2025 13:54:51 -0400 Subject: [PATCH 011/125] Closes #20295: Make cable terminations REST API endpoint read-only (#20394) --- contrib/openapi.json | 394 +------------------------ netbox/dcim/api/serializers_/cables.py | 10 +- netbox/dcim/api/views.py | 4 +- netbox/dcim/tests/test_api.py | 27 ++ 4 files changed, 41 insertions(+), 394 deletions(-) diff --git a/contrib/openapi.json b/contrib/openapi.json index e241789e6..86cbd6f63 100644 --- a/contrib/openapi.json +++ b/contrib/openapi.json @@ -21698,191 +21698,6 @@ "description": "" } } - }, - "post": { - "operationId": "dcim_cable_terminations_create", - "description": "Post a list of cable termination objects.", - "tags": [ - "dcim" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CableTerminationRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/CableTerminationRequest" - } - } - }, - "required": true - }, - "security": [ - { - "cookieAuth": [] - }, - { - "tokenAuth": [] - } - ], - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CableTermination" - } - } - }, - "description": "" - } - } - }, - "put": { - "operationId": "dcim_cable_terminations_bulk_update", - "description": "Put a list of cable termination objects.", - "tags": [ - "dcim" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CableTerminationRequest" - } - } - }, - "multipart/form-data": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CableTerminationRequest" - } - } - } - }, - "required": true - }, - "security": [ - { - "cookieAuth": [] - }, - { - "tokenAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CableTermination" - } - } - } - }, - "description": "" - } - } - }, - "patch": { - "operationId": "dcim_cable_terminations_bulk_partial_update", - "description": "Patch a list of cable termination objects.", - "tags": [ - "dcim" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CableTerminationRequest" - } - } - }, - "multipart/form-data": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CableTerminationRequest" - } - } - } - }, - "required": true - }, - "security": [ - { - "cookieAuth": [] - }, - { - "tokenAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CableTermination" - } - } - } - }, - "description": "" - } - } - }, - "delete": { - "operationId": "dcim_cable_terminations_bulk_destroy", - "description": "Delete a list of cable termination objects.", - "tags": [ - "dcim" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CableTerminationRequest" - } - } - }, - "multipart/form-data": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CableTerminationRequest" - } - } - } - }, - "required": true - }, - "security": [ - { - "cookieAuth": [] - }, - { - "tokenAuth": [] - } - ], - "responses": { - "204": { - "description": "No response body" - } - } } }, "/api/dcim/cable-terminations/{id}/": { @@ -21923,142 +21738,6 @@ "description": "" } } - }, - "put": { - "operationId": "dcim_cable_terminations_update", - "description": "Put a cable termination object.", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "description": "A unique integer value identifying this cable termination.", - "required": true - } - ], - "tags": [ - "dcim" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CableTerminationRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/CableTerminationRequest" - } - } - }, - "required": true - }, - "security": [ - { - "cookieAuth": [] - }, - { - "tokenAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CableTermination" - } - } - }, - "description": "" - } - } - }, - "patch": { - "operationId": "dcim_cable_terminations_partial_update", - "description": "Patch a cable termination object.", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "description": "A unique integer value identifying this cable termination.", - "required": true - } - ], - "tags": [ - "dcim" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchedCableTerminationRequest" - } - }, - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PatchedCableTerminationRequest" - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "tokenAuth": [] - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CableTermination" - } - } - }, - "description": "" - } - } - }, - "delete": { - "operationId": "dcim_cable_terminations_destroy", - "description": "Delete a cable termination object.", - "parameters": [ - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "description": "A unique integer value identifying this cable termination.", - "required": true - } - ], - "tags": [ - "dcim" - ], - "security": [ - { - "cookieAuth": [] - }, - { - "tokenAuth": [] - } - ], - "responses": { - "204": { - "description": "No response body" - } - } } }, "/api/dcim/cables/": { @@ -204463,7 +204142,8 @@ "readOnly": true }, "cable": { - "type": "integer" + "type": "integer", + "readOnly": true }, "cable_end": { "enum": [ @@ -204473,16 +204153,16 @@ "type": "string", "description": "* `A` - A\n* `B` - B", "x-spec-enum-id": "1db84f9b93b261c8", + "readOnly": true, "title": "End" }, "termination_type": { - "type": "string" + "type": "string", + "readOnly": true }, "termination_id": { "type": "integer", - "maximum": 9223372036854775807, - "minimum": 0, - "format": "int64" + "readOnly": true }, "termination": { "nullable": true, @@ -204514,40 +204194,6 @@ "url" ] }, - "CableTerminationRequest": { - "type": "object", - "description": "Adds support for custom fields and tags.", - "properties": { - "cable": { - "type": "integer" - }, - "cable_end": { - "enum": [ - "A", - "B" - ], - "type": "string", - "description": "* `A` - A\n* `B` - B", - "x-spec-enum-id": "1db84f9b93b261c8", - "title": "End" - }, - "termination_type": { - "type": "string" - }, - "termination_id": { - "type": "integer", - "maximum": 9223372036854775807, - "minimum": 0, - "format": "int64" - } - }, - "required": [ - "cable", - "cable_end", - "termination_id", - "termination_type" - ] - }, "Circuit": { "type": "object", "description": "Adds support for custom fields and tags.", @@ -226099,34 +225745,6 @@ } } }, - "PatchedCableTerminationRequest": { - "type": "object", - "description": "Adds support for custom fields and tags.", - "properties": { - "cable": { - "type": "integer" - }, - "cable_end": { - "enum": [ - "A", - "B" - ], - "type": "string", - "description": "* `A` - A\n* `B` - B", - "x-spec-enum-id": "1db84f9b93b261c8", - "title": "End" - }, - "termination_type": { - "type": "string" - }, - "termination_id": { - "type": "integer", - "maximum": 9223372036854775807, - "minimum": 0, - "format": "int64" - } - } - }, "PatchedCircuitGroupRequest": { "type": "object", "description": "Adds support for custom fields and tags.", diff --git a/netbox/dcim/api/serializers_/cables.py b/netbox/dcim/api/serializers_/cables.py index 397e5cd16..bb9a12462 100644 --- a/netbox/dcim/api/serializers_/cables.py +++ b/netbox/dcim/api/serializers_/cables.py @@ -1,10 +1,8 @@ -from django.contrib.contenttypes.models import ContentType from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from dcim.choices import * -from dcim.constants import * from dcim.models import Cable, CablePath, CableTermination from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.serializers import BaseModelSerializer, GenericObjectSerializer, NetBoxModelSerializer @@ -51,9 +49,11 @@ class TracedCableSerializer(BaseModelSerializer): class CableTerminationSerializer(NetBoxModelSerializer): termination_type = ContentTypeField( - queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS) + read_only=True, + ) + termination = serializers.SerializerMethodField( + read_only=True, ) - termination = serializers.SerializerMethodField(read_only=True) class Meta: model = CableTermination @@ -61,6 +61,8 @@ class CableTerminationSerializer(NetBoxModelSerializer): 'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id', 'termination', 'created', 'last_updated', ] + read_only_fields = fields + brief_fields = ('id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id') @extend_schema_field(serializers.JSONField(allow_null=True)) def get_termination(self, obj): diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index ffc0ca4d6..9ecaaa76a 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -16,7 +16,7 @@ from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.metadata import ContentTypeMetadata from netbox.api.pagination import StripCountAnnotationsPaginator -from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin +from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin, NetBoxReadOnlyModelViewSet from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin from utilities.api import get_serializer_for_model from utilities.query_functions import CollateAsChar @@ -563,7 +563,7 @@ class CableViewSet(NetBoxModelViewSet): filterset_class = filtersets.CableFilterSet -class CableTerminationViewSet(NetBoxModelViewSet): +class CableTerminationViewSet(NetBoxReadOnlyModelViewSet): metadata_class = ContentTypeMetadata queryset = CableTermination.objects.all() serializer_class = serializers.CableTerminationSerializer diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 6a819a3c0..d0a385887 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -2376,6 +2376,33 @@ class CableTest(APIViewTestCases.APIViewTestCase): ] +class CableTerminationTest( + APIViewTestCases.GetObjectViewTestCase, + APIViewTestCases.ListObjectsViewTestCase, +): + model = CableTermination + brief_fields = ['cable', 'cable_end', 'display', 'id', 'termination_id', 'termination_type', 'url'] + + @classmethod + def setUpTestData(cls): + device1 = create_test_device('Device 1') + device2 = create_test_device('Device 2') + + interfaces = [] + for device in (device1, device2): + for i in range(0, 10): + interfaces.append(Interface(device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED, name=f'eth{i}')) + Interface.objects.bulk_create(interfaces) + + cables = ( + Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[10]], label='Cable 1'), + Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[11]], label='Cable 2'), + Cable(a_terminations=[interfaces[2]], b_terminations=[interfaces[12]], label='Cable 3'), + ) + for cable in cables: + cable.save() + + class ConnectedDeviceTest(APITestCase): @classmethod From 1ee23ba6fa206668186baf8a0e3d2c140f85b333 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 2 Oct 2025 15:04:29 -0400 Subject: [PATCH 012/125] Initial work on #20210 --- contrib/openapi.json | 235 ++++++++++++++++-- docs/integrations/rest-api.md | 23 +- netbox/account/tables.py | 3 +- netbox/account/views.py | 2 +- netbox/core/tests/test_api.py | 4 +- netbox/netbox/api/authentication.py | 108 +++++--- netbox/netbox/configuration_testing.py | 4 + netbox/netbox/settings.py | 8 + netbox/netbox/tests/test_authentication.py | 124 +++++++-- netbox/templates/users/token.html | 19 +- netbox/users/api/serializers_/tokens.py | 28 +-- netbox/users/choices.py | 17 ++ netbox/users/constants.py | 4 + netbox/users/filtersets.py | 2 +- netbox/users/forms/bulk_import.py | 15 +- netbox/users/forms/filtersets.py | 7 +- netbox/users/forms/model_forms.py | 35 ++- .../users/migrations/0014_users_token_v2.py | 65 +++++ netbox/users/models/tokens.py | 140 +++++++++-- netbox/users/tables.py | 3 +- netbox/users/tests/test_api.py | 6 +- netbox/users/tests/test_filtersets.py | 30 ++- netbox/users/tests/test_views.py | 31 +-- netbox/users/utils.py | 17 ++ netbox/utilities/testing/api.py | 5 +- netbox/utilities/testing/views.py | 24 +- 26 files changed, 787 insertions(+), 172 deletions(-) create mode 100644 netbox/users/choices.py create mode 100644 netbox/users/migrations/0014_users_token_v2.py diff --git a/contrib/openapi.json b/contrib/openapi.json index 839aba0b4..3618a36af 100644 --- a/contrib/openapi.json +++ b/contrib/openapi.json @@ -166111,6 +166111,91 @@ "type": "string" } }, + { + "in": "query", + "name": "pepper", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "pepper__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "pepper__gt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "pepper__gte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "pepper__lt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "pepper__lte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "pepper__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, { "in": "query", "name": "q", @@ -166171,6 +166256,19 @@ "explode": true, "style": "form" }, + { + "in": "query", + "name": "version", + "schema": { + "type": "integer", + "x-spec-enum-id": "b5df70f0bffd12cb", + "enum": [ + 1, + 2 + ] + }, + "description": "* `1` - v1\n* `2` - v2" + }, { "in": "query", "name": "write_enabled", @@ -228068,6 +228166,17 @@ "type": "object", "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)", "properties": { + "version": { + "enum": [ + 1, + 2 + ], + "type": "integer", + "description": "* `1` - v1\n* `2` - v2", + "x-spec-enum-id": "b5df70f0bffd12cb", + "minimum": 0, + "maximum": 32767 + }, "user": { "oneOf": [ { @@ -228078,6 +228187,10 @@ } ] }, + "description": { + "type": "string", + "maxLength": 200 + }, "expires": { "type": "string", "format": "date-time", @@ -228088,19 +228201,20 @@ "format": "date-time", "nullable": true }, - "key": { - "type": "string", - "writeOnly": true, - "maxLength": 40, - "minLength": 40 - }, "write_enabled": { "type": "boolean", "description": "Permit create/update/delete operations using this key" }, - "description": { + "pepper": { + "type": "integer", + "maximum": 32767, + "minimum": 0, + "nullable": true, + "description": "ID of the cryptographic pepper used to hash the token (v2 only)" + }, + "token": { "type": "string", - "maxLength": 200 + "minLength": 1 } } }, @@ -244302,9 +244416,30 @@ "type": "string", "readOnly": true }, + "version": { + "enum": [ + 1, + 2 + ], + "type": "integer", + "description": "* `1` - v1\n* `2` - v2", + "x-spec-enum-id": "b5df70f0bffd12cb", + "minimum": 0, + "maximum": 32767 + }, + "key": { + "type": "string", + "readOnly": true, + "nullable": true, + "description": "v2 token identification key" + }, "user": { "$ref": "#/components/schemas/BriefUser" }, + "description": { + "type": "string", + "maxLength": 200 + }, "created": { "type": "string", "format": "date-time", @@ -244324,9 +244459,15 @@ "type": "boolean", "description": "Permit create/update/delete operations using this key" }, - "description": { - "type": "string", - "maxLength": 200 + "pepper": { + "type": "integer", + "maximum": 32767, + "minimum": 0, + "nullable": true, + "description": "ID of the cryptographic pepper used to hash the token (v2 only)" + }, + "token": { + "type": "string" } }, "required": [ @@ -244334,6 +244475,7 @@ "display", "display_url", "id", + "key", "url", "user" ] @@ -244360,6 +244502,17 @@ "type": "string", "readOnly": true }, + "version": { + "enum": [ + 1, + 2 + ], + "type": "integer", + "description": "* `1` - v1\n* `2` - v2", + "x-spec-enum-id": "b5df70f0bffd12cb", + "minimum": 0, + "maximum": 32767 + }, "user": { "allOf": [ { @@ -244368,6 +244521,10 @@ ], "readOnly": true }, + "key": { + "type": "string", + "readOnly": true + }, "created": { "type": "string", "format": "date-time", @@ -244383,10 +244540,6 @@ "format": "date-time", "readOnly": true }, - "key": { - "type": "string", - "readOnly": true - }, "write_enabled": { "type": "boolean", "description": "Permit create/update/delete operations using this key" @@ -244394,6 +244547,9 @@ "description": { "type": "string", "maxLength": 200 + }, + "token": { + "type": "string" } }, "required": [ @@ -244411,6 +244567,17 @@ "type": "object", "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)", "properties": { + "version": { + "enum": [ + 1, + 2 + ], + "type": "integer", + "description": "* `1` - v1\n* `2` - v2", + "x-spec-enum-id": "b5df70f0bffd12cb", + "minimum": 0, + "maximum": 32767 + }, "expires": { "type": "string", "format": "date-time", @@ -244433,6 +244600,10 @@ "type": "string", "writeOnly": true, "minLength": 1 + }, + "token": { + "type": "string", + "minLength": 1 } }, "required": [ @@ -244444,6 +244615,17 @@ "type": "object", "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)", "properties": { + "version": { + "enum": [ + 1, + 2 + ], + "type": "integer", + "description": "* `1` - v1\n* `2` - v2", + "x-spec-enum-id": "b5df70f0bffd12cb", + "minimum": 0, + "maximum": 32767 + }, "user": { "oneOf": [ { @@ -244454,6 +244636,10 @@ } ] }, + "description": { + "type": "string", + "maxLength": 200 + }, "expires": { "type": "string", "format": "date-time", @@ -244464,19 +244650,20 @@ "format": "date-time", "nullable": true }, - "key": { - "type": "string", - "writeOnly": true, - "maxLength": 40, - "minLength": 40 - }, "write_enabled": { "type": "boolean", "description": "Permit create/update/delete operations using this key" }, - "description": { + "pepper": { + "type": "integer", + "maximum": 32767, + "minimum": 0, + "nullable": true, + "description": "ID of the cryptographic pepper used to hash the token (v2 only)" + }, + "token": { "type": "string", - "maxLength": 200 + "minLength": 1 } }, "required": [ @@ -256709,7 +256896,7 @@ "type": "apiKey", "in": "header", "name": "Authorization", - "description": "Token-based authentication with required prefix \"Token\"" + "description": "Set `Token ` (v1) or `Bearer ` (v2) in the Authorization header" } } }, diff --git a/docs/integrations/rest-api.md b/docs/integrations/rest-api.md index 47fb65494..9cecbca3d 100644 --- a/docs/integrations/rest-api.md +++ b/docs/integrations/rest-api.md @@ -657,14 +657,17 @@ A token is a unique identifier mapped to a NetBox user account. Each user may ha By default, all users can create and manage their own REST API tokens under the user control panel in the UI or via the REST API. This ability can be disabled by overriding the [`DEFAULT_PERMISSIONS`](../configuration/security.md#default_permissions) configuration parameter. -Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation. +!!! info "Token Versions" + Beginning with NetBox v4.5, two types of API token are supported, denoted as v1 and v2. Users are strongly encouraged to create only v2 tokens, as these provide much stronger security than v1 tokens. Support for v1 tokens will be removed in a future NetBox release. + +When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation. Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox. !!! info "Restricting Token Retrieval" The ability to retrieve the key value of a previously-created API token can be restricted by disabling the [`ALLOW_TOKEN_RETRIEVAL`](../configuration/security.md#allow_token_retrieval) configuration parameter. -### Restricting Write Operations +#### Restricting Write Operations By default, a token can be used to perform all actions via the API that a user would be permitted to do via the web UI. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only. @@ -681,10 +684,22 @@ It is possible to provision authentication tokens for other users via the REST A ### Authenticating to the API -An authentication token is attached to a request by setting the `Authorization` header to the string `Token` followed by a space and the user's token: +An authentication token is included with a request in its `Authorization` header. The format of the header value depends on the version of token in use. v2 tokens use the following form, concatenating the token's key and plaintext value with a period: ``` -$ curl -H "Authorization: Token $TOKEN" \ +Authorization: Bearer . +``` + +v1 tokens use the prefix `Token` rather than `Bearer`, and include only the token plaintext. (v1 tokens do not have a key.) + +``` +Authorization: Token +``` + +Below is an example REST API request utilizing a v2 token. + +``` +$ curl -H "Authorization: Bearer ." \ -H "Accept: application/json; indent=4" \ https://netbox/api/dcim/sites/ { diff --git a/netbox/account/tables.py b/netbox/account/tables.py index bcc0a0ccd..0b15a8a13 100644 --- a/netbox/account/tables.py +++ b/netbox/account/tables.py @@ -53,5 +53,6 @@ class UserTokenTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = UserToken fields = ( - 'pk', 'id', 'key', 'description', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips', + 'pk', 'id', 'version', 'key', 'pepper', 'description', 'write_enabled', 'created', 'expires', 'last_used', + 'allowed_ips', ) diff --git a/netbox/account/views.py b/netbox/account/views.py index f5ef534ce..b513f04e4 100644 --- a/netbox/account/views.py +++ b/netbox/account/views.py @@ -343,7 +343,7 @@ class UserTokenView(LoginRequiredMixin, View): def get(self, request, pk): token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk) - key = token.key if settings.ALLOW_TOKEN_RETRIEVAL else None + key = token.key if token.v2 or settings.ALLOW_TOKEN_RETRIEVAL else None return render(request, 'account/token.html', { 'object': token, diff --git a/netbox/core/tests/test_api.py b/netbox/core/tests/test_api.py index 4d612e157..46070c4b4 100644 --- a/netbox/core/tests/test_api.py +++ b/netbox/core/tests/test_api.py @@ -135,8 +135,8 @@ class BackgroundTaskTestCase(TestCase): """ # Create the test user and assign permissions self.user = User.objects.create_user(username='testuser', is_active=True) - self.token = Token.objects.create(user=self.user) - self.header = {'HTTP_AUTHORIZATION': f'Token {self.token.key}'} + self.token = Token.objects.create(version=1, user=self.user) + self.header = {'HTTP_AUTHORIZATION': f'Token {self.token.token}'} # Clear all queues prior to running each test get_queue('default').connection.flushall() diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index f0bd5fd27..9c73259bf 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -2,47 +2,86 @@ import logging from django.conf import settings from django.utils import timezone -from rest_framework import authentication, exceptions +from drf_spectacular.extensions import OpenApiAuthenticationExtension +from rest_framework import exceptions +from rest_framework.authentication import BaseAuthentication, get_authorization_header from rest_framework.permissions import BasePermission, DjangoObjectPermissions, SAFE_METHODS from netbox.config import get_config from users.models import Token from utilities.request import get_client_ip +V1_KEYWORD = 'token' +V2_KEYWORD = 'bearer' -class TokenAuthentication(authentication.TokenAuthentication): + +class TokenAuthentication(BaseAuthentication): """ A custom authentication scheme which enforces Token expiration times and source IP restrictions. """ model = Token def authenticate(self, request): - result = super().authenticate(request) + if not (auth := get_authorization_header(request).split()): + return - if result: - token = result[1] + # Check for Token/Bearer keyword in HTTP header value & infer token version + if auth[0].lower() == V1_KEYWORD.lower().encode(): + version = 1 + elif auth[0].lower() == V2_KEYWORD.lower().encode(): + version = 2 + else: + return - # Enforce source IP restrictions (if any) set on the token - if token.allowed_ips: - client_ip = get_client_ip(request) - if client_ip is None: - raise exceptions.AuthenticationFailed( - "Client IP address could not be determined for validation. Check that the HTTP server is " - "correctly configured to pass the required header(s)." - ) - if not token.validate_client_ip(client_ip): - raise exceptions.AuthenticationFailed( - f"Source IP {client_ip} is not permitted to authenticate using this token." - ) - - return result - - def authenticate_credentials(self, key): - model = self.get_model() + # Extract token key from authorization header + if len(auth) != 2: + raise exceptions.AuthenticationFailed("Invalid authorization header: Error parsing token") try: - token = model.objects.prefetch_related('user').get(key=key) - except model.DoesNotExist: - raise exceptions.AuthenticationFailed("Invalid token") + auth_value = auth[1].decode() + except UnicodeError: + raise exceptions.AuthenticationFailed("Invalid authorization header: Token contains invalid characters") + + # Look for a matching token in the database + if version == 1: + key, plaintext = None, auth_value + else: + try: + key, plaintext = auth_value.split('.', 1) + except ValueError: + raise exceptions.AuthenticationFailed( + "Invalid authorization header: Could not parse key from v2 token. Did you mean to use 'Token' " + "instead of 'Bearer'?" + ) + try: + qs = Token.objects.prefetch_related('user') + if version == 1: + # Fetch v1 token by querying plaintext value directly + token = qs.get(version=version, plaintext=plaintext) + else: + # Fetch v2 token by key, then validate the plaintext + token = qs.get(version=version, key=key) + if not token.validate(plaintext): + # TODO: Consider security implications of enabling validation of token key without valid plaintext + raise exceptions.AuthenticationFailed(f"Validation failed for v2 token {key}") + except Token.DoesNotExist: + raise exceptions.AuthenticationFailed(f"Invalid v{version} token") + + # Enforce source IP restrictions (if any) set on the token + if token.allowed_ips: + client_ip = get_client_ip(request) + if client_ip is None: + raise exceptions.AuthenticationFailed( + "Client IP address could not be determined for validation. Check that the HTTP server is " + "correctly configured to pass the required header(s)." + ) + if not token.validate_client_ip(client_ip): + raise exceptions.AuthenticationFailed( + f"Source IP {client_ip} is not permitted to authenticate using this token." + ) + + # Enforce the Token's expiration time, if one has been set. + if token.is_expired: + raise exceptions.AuthenticationFailed("Token expired") # Update last used, but only once per minute at most. This reduces write load on the database if not token.last_used or (timezone.now() - token.last_used).total_seconds() > 60: @@ -54,11 +93,8 @@ class TokenAuthentication(authentication.TokenAuthentication): else: Token.objects.filter(pk=token.pk).update(last_used=timezone.now()) - # Enforce the Token's expiration time, if one has been set. - if token.is_expired: - raise exceptions.AuthenticationFailed("Token expired") - user = token.user + # When LDAP authentication is active try to load user data from LDAP directory if 'netbox.authentication.LDAPBackend' in settings.REMOTE_AUTH_BACKEND: from netbox.authentication import LDAPBackend @@ -132,3 +168,17 @@ class IsAuthenticatedOrLoginNotRequired(BasePermission): if not settings.LOGIN_REQUIRED: return True return request.user.is_authenticated + + +class TokenScheme(OpenApiAuthenticationExtension): + target_class = 'netbox.api.authentication.TokenAuthentication' + name = 'tokenAuth' + match_subclasses = True + + def get_security_definition(self, auto_schema): + return { + 'type': 'apiKey', + 'in': 'header', + 'name': 'Authorization', + 'description': 'Set `Token ` (v1) or `Bearer ` (v2) in the Authorization header', + } diff --git a/netbox/netbox/configuration_testing.py b/netbox/netbox/configuration_testing.py index 52973e94d..bbe6dbada 100644 --- a/netbox/netbox/configuration_testing.py +++ b/netbox/netbox/configuration_testing.py @@ -45,6 +45,10 @@ DEFAULT_PERMISSIONS = {} ALLOW_TOKEN_RETRIEVAL = True +API_TOKEN_PEPPERS = { + 0: 'TEST-VALUE-DO-NOT-USE', +} + LOGGING = { 'version': 1, 'disable_existing_loggers': True diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index c0d7f9230..a912c2d6e 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -65,6 +65,7 @@ elif hasattr(configuration, 'DATABASE') and hasattr(configuration, 'DATABASES'): ADMINS = getattr(configuration, 'ADMINS', []) ALLOW_TOKEN_RETRIEVAL = getattr(configuration, 'ALLOW_TOKEN_RETRIEVAL', False) ALLOWED_HOSTS = getattr(configuration, 'ALLOWED_HOSTS') # Required +API_TOKEN_PEPPERS = getattr(configuration, 'API_TOKEN_PEPPERS', {}) AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', [ { "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", @@ -215,6 +216,13 @@ if len(SECRET_KEY) < 50: f" python {BASE_DIR}/generate_secret_key.py" ) +# Validate API token peppers +for key in API_TOKEN_PEPPERS: + if type(key) is not int: + raise ImproperlyConfigured(f"Invalid API_TOKEN_PEPPERS key: {key}. All keys must be integers.") +if not API_TOKEN_PEPPERS: + warnings.warn("API_TOKEN_PEPPERS is not defined. v2 API tokens cannot be used.") + # Validate update repo URL and timeout if RELEASE_CHECK_URL: try: diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 9eb21661d..e30ae8700 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -16,35 +16,79 @@ from utilities.testing.api import APITestCase class TokenAuthenticationTestCase(APITestCase): @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) - def test_token_authentication(self): - url = reverse('dcim-api:site-list') - + def test_no_token(self): # Request without a token should return a 403 - response = self.client.get(url) + response = self.client.get(reverse('dcim-api:site-list')) self.assertEqual(response.status_code, 403) + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_v1_token_valid(self): + # Create a v1 token + token = Token.objects.create(version=1, user=self.user) + # Valid token should return a 200 - token = Token.objects.create(user=self.user) - response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}') - self.assertEqual(response.status_code, 200) + header = f'Token {token.token}' + response = self.client.get(reverse('dcim-api:site-list'), HTTP_AUTHORIZATION=header) + self.assertEqual(response.status_code, 200, response.data) # Check that the token's last_used time has been updated token.refresh_from_db() self.assertIsNotNone(token.last_used) + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_v1_token_invalid(self): + # Invalid token should return a 403 + header = 'Token XXXXXXXXXX' + response = self.client.get(reverse('dcim-api:site-list'), HTTP_AUTHORIZATION=header) + self.assertEqual(response.status_code, 403) + self.assertEqual(response.data['detail'], "Invalid v1 token") + + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_v2_token_valid(self): + # Create a v2 token + token = Token.objects.create(version=2, user=self.user) + + # Valid token should return a 200 + header = f'Bearer {token.key}.{token.token}' + response = self.client.get(reverse('dcim-api:site-list'), HTTP_AUTHORIZATION=header) + self.assertEqual(response.status_code, 200, response.data) + + # Check that the token's last_used time has been updated + token.refresh_from_db() + self.assertIsNotNone(token.last_used) + + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_v2_token_invalid(self): + # Invalid token should return a 403 + header = 'Bearer XXXXXXXXXX.XXXXXXXXXX' + response = self.client.get(reverse('dcim-api:site-list'), HTTP_AUTHORIZATION=header) + self.assertEqual(response.status_code, 403) + self.assertEqual(response.data['detail'], "Invalid v2 token") + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) def test_token_expiration(self): url = reverse('dcim-api:site-list') - # Request without a non-expired token should succeed - token = Token.objects.create(user=self.user) - response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}') + # Create v1 & v2 tokens + future = datetime.datetime(2100, 1, 1, tzinfo=datetime.timezone.utc) + token1 = Token.objects.create(version=1, user=self.user, expires=future) + token2 = Token.objects.create(version=2, user=self.user, expires=future) + + # Request with a non-expired token should succeed + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token1.token}') + self.assertEqual(response.status_code, 200) + response = self.client.get(url, HTTP_AUTHORIZATION=f'Bearer {token2.key}.{token2.token}') self.assertEqual(response.status_code, 200) # Request with an expired token should fail - token.expires = datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc) - token.save() - response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}') + past = datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc) + token1.expires = past + token1.save() + token2.expires = past + token2.save() + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token1.key}') + self.assertEqual(response.status_code, 403) + response = self.client.get(url, HTTP_AUTHORIZATION=f'Bearer {token2.key}') self.assertEqual(response.status_code, 403) @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) @@ -55,28 +99,60 @@ class TokenAuthenticationTestCase(APITestCase): 'slug': 'site-1', } + # Create v1 & v2 tokens + token1 = Token.objects.create(version=1, user=self.user, write_enabled=False) + token2 = Token.objects.create(version=2, user=self.user, write_enabled=False) + # Request with a write-disabled token should fail - token = Token.objects.create(user=self.user, write_enabled=False) - response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Token {token.key}') + response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Token {token1.token}') + self.assertEqual(response.status_code, 403) + response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Bearer {token2.key}.{token2.token}') self.assertEqual(response.status_code, 403) # Request with a write-enabled token should succeed - token.write_enabled = True - token.save() - response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Token {token.key}') + token1.write_enabled = True + token1.save() + token2.write_enabled = True + token2.save() + response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Token {token1.token}') + self.assertEqual(response.status_code, 403) + response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Bearer {token2.key}.{token2.token}') self.assertEqual(response.status_code, 403) @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) def test_token_allowed_ips(self): url = reverse('dcim-api:site-list') + # Create v1 & v2 tokens + token1 = Token.objects.create(version=1, user=self.user, allowed_ips=['192.0.2.0/24']) + token2 = Token.objects.create(version=2, user=self.user, allowed_ips=['192.0.2.0/24']) + # Request from a non-allowed client IP should fail - token = Token.objects.create(user=self.user, allowed_ips=['192.0.2.0/24']) - response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}', REMOTE_ADDR='127.0.0.1') + response = self.client.get( + url, + HTTP_AUTHORIZATION=f'Token {token1.token}', + REMOTE_ADDR='127.0.0.1' + ) + self.assertEqual(response.status_code, 403) + response = self.client.get( + url, + HTTP_AUTHORIZATION=f'Bearer {token2.key}.{token2.token}', + REMOTE_ADDR='127.0.0.1' + ) self.assertEqual(response.status_code, 403) - # Request with an expired token should fail - response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}', REMOTE_ADDR='192.0.2.1') + # Request from an allowed client IP should succeed + response = self.client.get( + url, + HTTP_AUTHORIZATION=f'Token {token1.token}', + REMOTE_ADDR='192.0.2.1' + ) + self.assertEqual(response.status_code, 200) + response = self.client.get( + url, + HTTP_AUTHORIZATION=f'Bearer {token2.key}.{token2.token}', + REMOTE_ADDR='192.0.2.1' + ) self.assertEqual(response.status_code, 200) @@ -426,8 +502,8 @@ class ObjectPermissionAPIViewTestCase(TestCase): Create a test user and token for API calls. """ self.user = User.objects.create(username='testuser') - self.token = Token.objects.create(user=self.user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)} + self.token = Token.objects.create(version=1, user=self.user) + self.header = {'HTTP_AUTHORIZATION': f'Token {self.token.token}'} @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_get_object(self): diff --git a/netbox/templates/users/token.html b/netbox/templates/users/token.html index 674476d51..039d02759 100644 --- a/netbox/templates/users/token.html +++ b/netbox/templates/users/token.html @@ -14,9 +14,24 @@

    {% trans "Token" %}

    - - + + + {% if object.version == 1 %} + + + + + {% else %} + + + + + + + + + {% endif %} - - + + {% endif %} diff --git a/netbox/users/api/serializers_/tokens.py b/netbox/users/api/serializers_/tokens.py index f7da4dd13..3b5ec08ee 100644 --- a/netbox/users/api/serializers_/tokens.py +++ b/netbox/users/api/serializers_/tokens.py @@ -32,7 +32,7 @@ class TokenSerializer(ValidatedModelSerializer): model = Token fields = ( 'id', 'url', 'display_url', 'display', 'version', 'key', 'user', 'description', 'created', 'expires', - 'last_used', 'write_enabled', 'pepper', 'allowed_ips', 'token', + 'last_used', 'write_enabled', 'pepper_id', 'allowed_ips', 'token', ) read_only_fields = ('key',) brief_fields = ('id', 'url', 'display', 'version', 'key', 'write_enabled', 'description') diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index 3cbef8b1a..a67761354 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -133,7 +133,7 @@ class TokenFilterSet(BaseFilterSet): class Meta: model = Token - fields = ('id', 'version', 'key', 'pepper', 'write_enabled', 'description', 'last_used') + fields = ('id', 'version', 'key', 'pepper_id', 'write_enabled', 'description', 'last_used') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/users/migrations/0014_users_token_v2.py b/netbox/users/migrations/0014_users_token_v2.py index 39f1cbf48..ab0301244 100644 --- a/netbox/users/migrations/0014_users_token_v2.py +++ b/netbox/users/migrations/0014_users_token_v2.py @@ -64,7 +64,7 @@ class Migration(migrations.Migration): ), migrations.AddField( model_name='token', - name='pepper', + name='pepper_id', field=models.PositiveSmallIntegerField(blank=True, null=True), ), migrations.AddField( diff --git a/netbox/users/models/tokens.py b/netbox/users/models/tokens.py index 96aa8e821..fe112c1c1 100644 --- a/netbox/users/models/tokens.py +++ b/netbox/users/models/tokens.py @@ -80,8 +80,8 @@ class Token(models.Model): validators=[MinLengthValidator(TOKEN_KEY_LENGTH)], help_text=_('v2 token identification key'), ) - pepper = models.PositiveSmallIntegerField( - verbose_name=_('pepper'), + pepper_id = models.PositiveSmallIntegerField( + verbose_name=_('pepper ID'), blank=True, null=True, help_text=_('ID of the cryptographic pepper used to hash the token (v2 only)'), @@ -179,7 +179,7 @@ class Token(models.Model): """ Recalculate and save the HMAC digest using the currently defined pepper and token values. """ - self.pepper, pepper_value = get_current_pepper() + self.pepper_id, pepper_value = get_current_pepper() self.hmac_digest = hmac.new( pepper_value.encode('utf-8'), self.token.encode('utf-8'), @@ -202,7 +202,7 @@ class Token(models.Model): return token == self.key if self.v2: try: - pepper = settings.API_TOKEN_PEPPERS[self.pepper] + pepper = settings.API_TOKEN_PEPPERS[self.pepper_id] except KeyError: # Invalid pepper ID return False diff --git a/netbox/users/tables.py b/netbox/users/tables.py index b8683cc87..249803840 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -22,7 +22,7 @@ class TokenTable(UserTokenTable): class Meta(NetBoxTable.Meta): model = Token fields = ( - 'pk', 'id', 'version', 'key', 'pepper', 'user', 'description', 'write_enabled', 'created', 'expires', + 'pk', 'id', 'version', 'key', 'pepper_id', 'user', 'description', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips', ) From 43fc7fb58aebe7f23252c64114d8dd02572056a0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 2 Oct 2025 16:05:09 -0400 Subject: [PATCH 015/125] Add constraints to enforce v1/v2-dependent fields --- .../users/migrations/0014_users_token_v2.py | 25 +++++++++++++++++++ netbox/users/models/tokens.py | 22 ++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/netbox/users/migrations/0014_users_token_v2.py b/netbox/users/migrations/0014_users_token_v2.py index ab0301244..1c0bb5c1d 100644 --- a/netbox/users/migrations/0014_users_token_v2.py +++ b/netbox/users/migrations/0014_users_token_v2.py @@ -72,4 +72,29 @@ class Migration(migrations.Migration): name='hmac_digest', field=models.CharField(blank=True, max_length=64, null=True), ), + + # Add constraints to enforce v1/v2-dependent fields + migrations.AddConstraint( + model_name='token', + constraint=models.CheckConstraint( + name='enforce_version_dependent_fields', + condition=models.Q( + models.Q( + ('hmac_digest__isnull', True), + ('key__isnull', True), + ('pepper_id__isnull', True), + ('plaintext__isnull', False), + ('version', 1) + ), + models.Q( + ('hmac_digest__isnull', False), + ('key__isnull', False), + ('pepper_id__isnull', False), + ('plaintext__isnull', True), + ('version', 2) + ), + _connector='OR' + ) + ) + ), ] diff --git a/netbox/users/models/tokens.py b/netbox/users/models/tokens.py index fe112c1c1..3e8e0f108 100644 --- a/netbox/users/models/tokens.py +++ b/netbox/users/models/tokens.py @@ -7,6 +7,7 @@ from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError from django.core.validators import MinLengthValidator from django.db import models +from django.db.models import Q from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -110,6 +111,27 @@ class Token(models.Model): ordering = ('-created',) verbose_name = _('token') verbose_name_plural = _('tokens') + constraints = [ + models.CheckConstraint( + name='enforce_version_dependent_fields', + condition=( + Q( + version=1, + key__isnull=True, + pepper_id__isnull=True, + hmac_digest__isnull=True, + plaintext__isnull=False + ) | + Q( + version=2, + key__isnull=False, + pepper_id__isnull=False, + hmac_digest__isnull=False, + plaintext__isnull=True + ) + ), + ), + ] def __init__(self, *args, token=None, **kwargs): super().__init__(*args, **kwargs) From f82f084c02f0d1b2aafc6650b7174dbc541686aa Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 2 Oct 2025 16:33:04 -0400 Subject: [PATCH 016/125] Misc cleanup --- netbox/users/models/tokens.py | 36 +++++++++++++++++++++++------------ netbox/users/utils.py | 2 +- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/netbox/users/models/tokens.py b/netbox/users/models/tokens.py index 3e8e0f108..e452d2ab7 100644 --- a/netbox/users/models/tokens.py +++ b/netbox/users/models/tokens.py @@ -29,6 +29,8 @@ class Token(models.Model): An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens. It also supports setting an expiration time and toggling write ability. """ + _token = None + version = models.PositiveSmallIntegerField( verbose_name=_('version'), choices=TokenVersionChoices, @@ -136,12 +138,12 @@ class Token(models.Model): def __init__(self, *args, token=None, **kwargs): super().__init__(*args, **kwargs) + # This stores the initial plaintext value (if given) on the creation of a new Token. If not provided, a + # random token value will be generated and assigned immediately prior to saving the Token instance. self.token = token def __str__(self): - if self.v1: - return self.partial - return self.key + return self.key if self.v2 else self.partial def get_absolute_url(self): return reverse('users:token', args=[self.pk]) @@ -156,14 +158,19 @@ class Token(models.Model): @property def partial(self): + """ + Return a sanitized representation of a v1 token. + """ return f'**********************************{self.plaintext[-6:]}' if self.plaintext else '' @property def token(self): - return getattr(self, '_token', None) + return self._token @token.setter def token(self, value): + if not self._state.adding: + raise ValueError("Cannot assign a new plaintext value for an existing token.") self._token = value if value is not None: if self.v1: @@ -173,8 +180,11 @@ class Token(models.Model): self.update_digest() def clean(self): - if self._state.adding and self.v2 and not settings.API_TOKEN_PEPPERS: - raise ValidationError(_("Cannot create v2 tokens: API_TOKEN_PEPPERS is not defined.")) + if self._state.adding: + if self.pepper_id is not None and self.pepper_id not in settings.API_TOKEN_PEPPERS: + raise ValidationError(_( + "Invalid pepper ID: {id}. Check configured API_TOKEN_PEPPERS." + ).format(id=self.pepper_id)) def save(self, *args, **kwargs): # If creating a new Token and no token value has been specified, generate one @@ -201,9 +211,9 @@ class Token(models.Model): """ Recalculate and save the HMAC digest using the currently defined pepper and token values. """ - self.pepper_id, pepper_value = get_current_pepper() + self.pepper_id, pepper = get_current_pepper() self.hmac_digest = hmac.new( - pepper_value.encode('utf-8'), + pepper.encode('utf-8'), self.token.encode('utf-8'), hashlib.sha256 ).hexdigest() @@ -216,12 +226,14 @@ class Token(models.Model): def validate(self, token): """ - Returns true if the given token value validates. + Validate the given plaintext against the token. + + For v1 tokens, check that the given value is equal to the stored plaintext. For v2 tokens, calculate an HMAC + from the Token's pepper ID and the given plaintext value, and check whether the result matches the recorded + digest. """ - if self.is_expired: - return False if self.v1: - return token == self.key + return token == self.token if self.v2: try: pepper = settings.API_TOKEN_PEPPERS[self.pepper_id] diff --git a/netbox/users/utils.py b/netbox/users/utils.py index 045d192c7..5db8cb65e 100644 --- a/netbox/users/utils.py +++ b/netbox/users/utils.py @@ -22,5 +22,5 @@ def get_current_pepper(): """ if len(settings.API_TOKEN_PEPPERS) < 1: raise ImproperlyConfigured("Must define API_TOKEN_PEPPERS to use v2 API tokens") - newest_id = sorted(settings.API_TOKEN_PEPPERS)[-1] + newest_id = sorted(settings.API_TOKEN_PEPPERS.keys())[-1] return newest_id, settings.API_TOKEN_PEPPERS[newest_id] From adce67a7cfc47e7851a63ce8567b96401cc36f99 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 2 Oct 2025 16:37:28 -0400 Subject: [PATCH 017/125] Standardize on the use of v2 tokens in tests --- netbox/core/tests/test_api.py | 4 ++-- netbox/netbox/tests/test_authentication.py | 4 ++-- netbox/utilities/testing/api.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/netbox/core/tests/test_api.py b/netbox/core/tests/test_api.py index 46070c4b4..29530bfa6 100644 --- a/netbox/core/tests/test_api.py +++ b/netbox/core/tests/test_api.py @@ -135,8 +135,8 @@ class BackgroundTaskTestCase(TestCase): """ # Create the test user and assign permissions self.user = User.objects.create_user(username='testuser', is_active=True) - self.token = Token.objects.create(version=1, user=self.user) - self.header = {'HTTP_AUTHORIZATION': f'Token {self.token.token}'} + self.token = Token.objects.create(user=self.user) + self.header = {'HTTP_AUTHORIZATION': f'Bearer {self.token.key}.{self.token.token}'} # Clear all queues prior to running each test get_queue('default').connection.flushall() diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index e30ae8700..bd7d41186 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -502,8 +502,8 @@ class ObjectPermissionAPIViewTestCase(TestCase): Create a test user and token for API calls. """ self.user = User.objects.create(username='testuser') - self.token = Token.objects.create(version=1, user=self.user) - self.header = {'HTTP_AUTHORIZATION': f'Token {self.token.token}'} + self.token = Token.objects.create(user=self.user) + self.header = {'HTTP_AUTHORIZATION': f'Bearer {self.token.key}.{self.token.token}'} @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_get_object(self): diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index 32e5fe53f..973b05cb3 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -49,8 +49,8 @@ class APITestCase(ModelTestCase): # Create the test user and assign permissions self.user = User.objects.create_user(username='testuser') self.add_permissions(*self.user_permissions) - self.token = Token.objects.create(version=1, user=self.user) - self.header = {'HTTP_AUTHORIZATION': f'Token {self.token.plaintext}'} + self.token = Token.objects.create(user=self.user) + self.header = {'HTTP_AUTHORIZATION': f'Bearer {self.token.key}.{self.token.token}'} def _get_view_namespace(self): return f'{self.view_namespace or self.model._meta.app_label}-api' From f6290dd7af2c1879e7b0560ff485ad1194c3a3d2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 3 Oct 2025 09:16:50 -0400 Subject: [PATCH 018/125] Toggle plaintext display for v1 tokens --- netbox/templates/account/token.html | 62 ++--------------------------- netbox/templates/users/token.html | 9 ++++- 2 files changed, 12 insertions(+), 59 deletions(-) diff --git a/netbox/templates/account/token.html b/netbox/templates/account/token.html index f8ce7badd..6fcf9e359 100644 --- a/netbox/templates/account/token.html +++ b/netbox/templates/account/token.html @@ -1,62 +1,8 @@ -{% extends 'generic/object.html' %} -{% load form_helpers %} -{% load helpers %} +{% extends 'users/token.html' %} {% load i18n %} -{% load plugins %} {% block breadcrumbs %} - + {% endblock breadcrumbs %} - -{% block title %}{% trans "Token" %} {{ object }}{% endblock %} - -{% block subtitle %}{% endblock %} - -{% block content %} -
    -
    -
    -

    {% trans "Token" %}

    -
    {% trans "Key" %}{% if settings.ALLOW_TOKEN_RETRIEVAL %}{{ object.key }}{% else %}{{ object.partial }}{% endif %}{% trans "Version" %}{{ object.version }}
    {% trans "Token" %}{{ object.partial }}
    {% trans "Key" %}{{ object }}
    {% trans "Pepper" %}{{ object.pepper }}
    {% trans "User" %} diff --git a/netbox/users/api/serializers_/tokens.py b/netbox/users/api/serializers_/tokens.py index 150291ee6..f7da4dd13 100644 --- a/netbox/users/api/serializers_/tokens.py +++ b/netbox/users/api/serializers_/tokens.py @@ -1,4 +1,3 @@ -from django.conf import settings from django.contrib.auth import authenticate from rest_framework import serializers from rest_framework.exceptions import AuthenticationFailed, PermissionDenied @@ -15,14 +14,13 @@ __all__ = ( class TokenSerializer(ValidatedModelSerializer): - key = serializers.CharField( - min_length=40, - max_length=40, - allow_blank=True, + token = serializers.CharField( required=False, - write_only=not settings.ALLOW_TOKEN_RETRIEVAL + default=Token.generate, + ) + user = UserSerializer( + nested=True ) - user = UserSerializer(nested=True) allowed_ips = serializers.ListField( child=IPNetworkSerializer(), required=False, @@ -33,15 +31,11 @@ class TokenSerializer(ValidatedModelSerializer): class Meta: model = Token fields = ( - 'id', 'url', 'display_url', 'display', 'user', 'created', 'expires', 'last_used', 'key', 'write_enabled', - 'description', 'allowed_ips', + 'id', 'url', 'display_url', 'display', 'version', 'key', 'user', 'description', 'created', 'expires', + 'last_used', 'write_enabled', 'pepper', 'allowed_ips', 'token', ) - brief_fields = ('id', 'url', 'display', 'key', 'write_enabled', 'description') - - def to_internal_value(self, data): - if not getattr(self.instance, 'key', None) and 'key' not in data: - data['key'] = Token.generate_key() - return super().to_internal_value(data) + read_only_fields = ('key',) + brief_fields = ('id', 'url', 'display', 'version', 'key', 'write_enabled', 'description') def validate(self, data): @@ -75,8 +69,8 @@ class TokenProvisionSerializer(TokenSerializer): class Meta: model = Token fields = ( - 'id', 'url', 'display_url', 'display', 'user', 'created', 'expires', 'last_used', 'key', 'write_enabled', - 'description', 'allowed_ips', 'username', 'password', + 'id', 'url', 'display_url', 'display', 'version', 'user', 'key', 'created', 'expires', 'last_used', 'key', + 'write_enabled', 'description', 'allowed_ips', 'username', 'password', 'token', ) def validate(self, data): diff --git a/netbox/users/choices.py b/netbox/users/choices.py new file mode 100644 index 000000000..547633c4e --- /dev/null +++ b/netbox/users/choices.py @@ -0,0 +1,17 @@ +from django.utils.translation import gettext_lazy as _ + +from utilities.choices import ChoiceSet + +__all__ = ( + 'TokenVersionChoices', +) + + +class TokenVersionChoices(ChoiceSet): + V1 = 1 + V2 = 2 + + CHOICES = [ + (V1, _('v1')), + (V2, _('v2')), + ] diff --git a/netbox/users/constants.py b/netbox/users/constants.py index e92623c82..b02c482e0 100644 --- a/netbox/users/constants.py +++ b/netbox/users/constants.py @@ -1,3 +1,5 @@ +import string + from django.db.models import Q @@ -7,3 +9,5 @@ OBJECTPERMISSION_OBJECT_TYPES = Q( ) CONSTRAINT_TOKEN_USER = '$user' + +TOKEN_CHARSET = string.ascii_letters + string.digits diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index 4e1510410..3cbef8b1a 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -133,7 +133,7 @@ class TokenFilterSet(BaseFilterSet): class Meta: model = Token - fields = ('id', 'key', 'write_enabled', 'description', 'last_used') + fields = ('id', 'version', 'key', 'pepper', 'write_enabled', 'description', 'last_used') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/users/forms/bulk_import.py b/netbox/users/forms/bulk_import.py index f478dedbf..bdda61a44 100644 --- a/netbox/users/forms/bulk_import.py +++ b/netbox/users/forms/bulk_import.py @@ -1,6 +1,7 @@ from django import forms from django.utils.translation import gettext as _ from users.models import * +from users.choices import TokenVersionChoices from utilities.forms import CSVModelForm @@ -34,12 +35,18 @@ class UserImportForm(CSVModelForm): class TokenImportForm(CSVModelForm): - key = forms.CharField( - label=_('Key'), + version = forms.ChoiceField( + choices=TokenVersionChoices, + initial=TokenVersionChoices.V2, required=False, - help_text=_("If no key is provided, one will be generated automatically.") + help_text=_("Specify version 1 or 2 (v2 will be used by default)") + ) + token = forms.CharField( + label=_('Token'), + required=False, + help_text=_("If no token is provided, one will be generated automatically.") ) class Meta: model = Token - fields = ('user', 'key', 'write_enabled', 'expires', 'description',) + fields = ('user', 'version', 'token', 'write_enabled', 'expires', 'description',) diff --git a/netbox/users/forms/filtersets.py b/netbox/users/forms/filtersets.py index 61e55949c..96f5a48d2 100644 --- a/netbox/users/forms/filtersets.py +++ b/netbox/users/forms/filtersets.py @@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelFilterSetForm from netbox.forms.mixins import SavedFiltersMixin +from users.choices import TokenVersionChoices from users.models import Group, ObjectPermission, Token, User from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm from utilities.forms.fields import DynamicModelMultipleChoiceField @@ -110,7 +111,11 @@ class TokenFilterForm(SavedFiltersMixin, FilterForm): model = Token fieldsets = ( FieldSet('q', 'filter_id',), - FieldSet('user_id', 'write_enabled', 'expires', 'last_used', name=_('Token')), + FieldSet('version', 'user_id', 'write_enabled', 'expires', 'last_used', name=_('Token')), + ) + version = forms.ChoiceField( + choices=TokenVersionChoices, + required=False, ) user_id = DynamicModelMultipleChoiceField( queryset=User.objects.all(), diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index 4f4e2fd43..9b6c8aaba 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -12,6 +12,7 @@ from core.models import ObjectType from ipam.formfields import IPNetworkFormField from ipam.validators import prefix_validator from netbox.preferences import PREFERENCES +from users.choices import TokenVersionChoices from users.constants import * from users.models import * from utilities.data import flatten_dict @@ -115,10 +116,10 @@ class UserConfigForm(forms.ModelForm, metaclass=UserConfigFormMetaclass): class UserTokenForm(forms.ModelForm): - key = forms.CharField( - label=_('Key'), + token = forms.CharField( + label=_('Token'), help_text=_( - 'Keys must be at least 40 characters in length. Be sure to record your key prior to ' + 'Tokens must be at least 40 characters in length. Be sure to record your key prior to ' 'submitting this form, as it may no longer be accessible once the token has been created.' ), widget=forms.TextInput( @@ -138,7 +139,7 @@ class UserTokenForm(forms.ModelForm): class Meta: model = Token fields = [ - 'key', 'write_enabled', 'expires', 'description', 'allowed_ips', + 'version', 'token', 'write_enabled', 'expires', 'description', 'allowed_ips', ] widgets = { 'expires': DateTimePicker(), @@ -147,13 +148,27 @@ class UserTokenForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Omit the key field if token retrieval is not permitted - if self.instance.pk and not settings.ALLOW_TOKEN_RETRIEVAL: - del self.fields['key'] + if self.instance.pk: + # Disable the version & user fields for existing Tokens + self.fields['version'].disabled = True + self.fields['user'].disabled = True + + # Omit the key field when editing an existing token if token retrieval is not permitted + if self.instance.v1 and settings.ALLOW_TOKEN_RETRIEVAL: + self.fields['token'].initial = self.instance.key + else: + del self.fields['token'] # Generate an initial random key if none has been specified - if not self.instance.pk and not self.initial.get('key'): - self.initial['key'] = Token.generate_key() + if self.instance._state.adding and not self.initial.get('token'): + self.initial['version'] = TokenVersionChoices.V2 + self.initial['token'] = Token.generate() + + def save(self, commit=True): + if self.cleaned_data.get('token'): + self.instance.token = self.cleaned_data['token'] + + return super().save(commit=commit) class TokenForm(UserTokenForm): @@ -165,7 +180,7 @@ class TokenForm(UserTokenForm): class Meta: model = Token fields = [ - 'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips', + 'version', 'token', 'user', 'write_enabled', 'expires', 'description', 'allowed_ips', ] widgets = { 'expires': DateTimePicker(), diff --git a/netbox/users/migrations/0014_users_token_v2.py b/netbox/users/migrations/0014_users_token_v2.py new file mode 100644 index 000000000..9e18e4a72 --- /dev/null +++ b/netbox/users/migrations/0014_users_token_v2.py @@ -0,0 +1,65 @@ +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0013_user_remove_is_staff'), + ] + + operations = [ + # Rename the original key field to "plaintext" + migrations.RenameField( + model_name='token', + old_name='key', + new_name='plaintext', + ), + migrations.RunSQL( + sql="ALTER INDEX IF EXISTS users_token_key_820deccd_like RENAME TO users_token_plaintext_46c6f315_like", + ), + migrations.RunSQL( + sql="ALTER INDEX IF EXISTS users_token_key_key RENAME TO users_token_plaintext_key", + ), + # Make plaintext (formerly key) nullable for v2 tokens + migrations.AlterField( + model_name='token', + name='plaintext', + field=models.CharField( + max_length=40, + unique=True, + blank=True, + null=True, + validators=[django.core.validators.MinLengthValidator(40)] + ), + ), + # Add version field to distinguish v1 and v2 tokens + migrations.AddField( + model_name='token', + name='version', + field=models.PositiveSmallIntegerField(default=1), # Mark all existing Tokens as v1 + preserve_default=False, + ), + # Change the default version for new tokens to v2 + migrations.AlterField( + model_name='token', + name='version', + field=models.PositiveSmallIntegerField(default=2), + ), + # Add new key, pepper, and hmac_digest fields for v2 tokens + migrations.AddField( + model_name='token', + name='key', + field=models.CharField(blank=True, max_length=16, null=True, unique=True), + ), + migrations.AddField( + model_name='token', + name='pepper', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='token', + name='hmac_digest', + field=models.CharField(blank=True, max_length=64, null=True), + ), + ] diff --git a/netbox/users/models/tokens.py b/netbox/users/models/tokens.py index 3c1284bc9..cf35c4e6a 100644 --- a/netbox/users/models/tokens.py +++ b/netbox/users/models/tokens.py @@ -1,8 +1,12 @@ import binascii +import hashlib +import hmac +import random import os from django.conf import settings from django.contrib.postgres.fields import ArrayField +from django.core.exceptions import ValidationError from django.core.validators import MinLengthValidator from django.db import models from django.urls import reverse @@ -11,6 +15,9 @@ from django.utils.translation import gettext_lazy as _ from netaddr import IPNetwork from ipam.fields import IPNetworkField +from users.choices import TokenVersionChoices +from users.constants import TOKEN_CHARSET +from users.utils import get_current_pepper from utilities.querysets import RestrictedQuerySet __all__ = ( @@ -23,11 +30,21 @@ class Token(models.Model): An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens. It also supports setting an expiration time and toggling write ability. """ + version = models.PositiveSmallIntegerField( + verbose_name=_('version'), + choices=TokenVersionChoices, + default=TokenVersionChoices.V2, + ) user = models.ForeignKey( to='users.User', on_delete=models.CASCADE, related_name='tokens' ) + description = models.CharField( + verbose_name=_('description'), + max_length=200, + blank=True + ) created = models.DateTimeField( verbose_name=_('created'), auto_now_add=True @@ -42,21 +59,40 @@ class Token(models.Model): blank=True, null=True ) - key = models.CharField( - verbose_name=_('key'), - max_length=40, - unique=True, - validators=[MinLengthValidator(40)] - ) write_enabled = models.BooleanField( verbose_name=_('write enabled'), default=True, help_text=_('Permit create/update/delete operations using this key') ) - description = models.CharField( - verbose_name=_('description'), - max_length=200, - blank=True + # For legacy v1 tokens, this field stores the plaintext 40-char token value. Not used for v2. + plaintext = models.CharField( + verbose_name=_('plaintext'), + max_length=40, + unique=True, + blank=True, + null=True, + validators=[MinLengthValidator(40)], + ) + key = models.CharField( + verbose_name=_('key'), + max_length=16, + unique=True, + blank=True, + null=True, + help_text=_('v2 token identification key'), + ) + pepper = models.PositiveSmallIntegerField( + verbose_name=_('pepper'), + blank=True, + null=True, + help_text=_('ID of the cryptographic pepper used to hash the token (v2 only)'), + ) + hmac_digest = models.CharField( + verbose_name=_('digest'), + max_length=64, + blank=True, + null=True, + help_text=_('SHA256 hash of the token and pepper (v2 only)'), ) allowed_ips = ArrayField( base_field=IPNetworkField(), @@ -72,36 +108,108 @@ class Token(models.Model): objects = RestrictedQuerySet.as_manager() class Meta: + ordering = ('-created',) verbose_name = _('token') verbose_name_plural = _('tokens') - ordering = ('-created',) + + def __init__(self, *args, token=None, **kwargs): + super().__init__(*args, **kwargs) + + self.token = token def __str__(self): - return self.key if settings.ALLOW_TOKEN_RETRIEVAL else self.partial + if self.v1: + return self.partial + return self.key def get_absolute_url(self): return reverse('users:token', args=[self.pk]) + @property + def v1(self): + return self.version == 1 + + @property + def v2(self): + return self.version == 2 + @property def partial(self): - return f'**********************************{self.key[-6:]}' if self.key else '' + return f'**********************************{self.plaintext[-6:]}' if self.plaintext else '' + + @property + def token(self): + return getattr(self, '_token', None) + + @token.setter + def token(self, value): + self._token = value + if value is not None: + if self.v1: + self.plaintext = value + elif self.v2: + self.key = self.key or self.generate(16) + self.update_digest() + + def clean(self): + if self._state.adding and self.v2 and not settings.API_TOKEN_PEPPERS: + raise ValidationError(_("Cannot create v2 tokens: API_TOKEN_PEPPERS is not defined.")) def save(self, *args, **kwargs): - if not self.key: - self.key = self.generate_key() + # If creating a new Token and no token value has been specified, generate one + if self._state.adding and self.token is None: + self.token = self.generate() + return super().save(*args, **kwargs) @staticmethod def generate_key(): - # Generate a random 160-bit key expressed in hexadecimal. + """ + DEPRECATED: Generate and return a random 160-bit key expressed in hexadecimal. + """ return binascii.hexlify(os.urandom(20)).decode() + @staticmethod + def generate(length=40): + """ + Generate and return a random token value of the given length. + """ + return ''.join(random.choice(TOKEN_CHARSET) for _ in range(length)) + + def update_digest(self): + """ + Recalculate and save the HMAC digest using the currently defined pepper and token values. + """ + self.pepper, pepper_value = get_current_pepper() + self.hmac_digest = hmac.new( + pepper_value.encode('utf-8'), + self.token.encode('utf-8'), + hashlib.sha256 + ).hexdigest() + @property def is_expired(self): if self.expires is None or timezone.now() < self.expires: return False return True + def validate(self, token): + """ + Returns true if the given token value validates. + """ + if self.is_expired: + return False + if self.v1: + return token == self.key + if self.v2: + try: + pepper = settings.API_TOKEN_PEPPERS[self.pepper] + except KeyError: + # Invalid pepper ID + return False + digest = hmac.new(pepper.encode('utf-8'), token.encode('utf-8'), hashlib.sha256).hexdigest() + return digest == self.hmac_digest + def validate_client_ip(self, client_ip): """ Validate the API client IP address against the source IP restrictions (if any) set on the token. diff --git a/netbox/users/tables.py b/netbox/users/tables.py index 40cbeca47..b8683cc87 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -22,7 +22,8 @@ class TokenTable(UserTokenTable): class Meta(NetBoxTable.Meta): model = Token fields = ( - 'pk', 'id', 'key', 'user', 'description', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips', + 'pk', 'id', 'version', 'key', 'pepper', 'user', 'description', 'write_enabled', 'created', 'expires', + 'last_used', 'allowed_ips', ) diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py index 71496f007..f0218179a 100644 --- a/netbox/users/tests/test_api.py +++ b/netbox/users/tests/test_api.py @@ -197,7 +197,7 @@ class TokenTest( APIViewTestCases.DeleteObjectViewTestCase ): model = Token - brief_fields = ['description', 'display', 'id', 'key', 'url', 'write_enabled'] + brief_fields = ['description', 'display', 'id', 'key', 'url', 'version', 'write_enabled'] bulk_update_data = { 'description': 'New description', } @@ -256,8 +256,8 @@ class TokenTest( response = self.client.post(url, data, format='json', **self.header) self.assertEqual(response.status_code, 201) - self.assertIn('key', response.data) - self.assertEqual(len(response.data['key']), 40) + self.assertIn('token', response.data) + self.assertEqual(len(response.data['token']), 40) self.assertEqual(response.data['description'], data['description']) self.assertEqual(response.data['expires'], data['expires']) token = Token.objects.get(user=user) diff --git a/netbox/users/tests/test_filtersets.py b/netbox/users/tests/test_filtersets.py index e15df0d18..f7404cedd 100644 --- a/netbox/users/tests/test_filtersets.py +++ b/netbox/users/tests/test_filtersets.py @@ -266,7 +266,7 @@ class ObjectPermissionTestCase(TestCase, BaseFilterSetTests): class TokenTestCase(TestCase, BaseFilterSetTests): queryset = Token.objects.all() filterset = filtersets.TokenFilterSet - ignore_fields = ('allowed_ips',) + ignore_fields = ('plaintext', 'hmac_digest', 'allowed_ips') @classmethod def setUpTestData(cls): @@ -282,21 +282,39 @@ class TokenTestCase(TestCase, BaseFilterSetTests): past_date = make_aware(datetime.datetime(2000, 1, 1)) tokens = ( Token( - user=users[0], key=Token.generate_key(), expires=future_date, write_enabled=True, description='foobar1' + version=1, + user=users[0], + expires=future_date, + write_enabled=True, + description='foobar1', ), Token( - user=users[1], key=Token.generate_key(), expires=future_date, write_enabled=True, description='foobar2' + version=2, + user=users[1], + expires=future_date, + write_enabled=True, + description='foobar2', ), Token( - user=users[2], key=Token.generate_key(), expires=past_date, write_enabled=False + version=2, + user=users[2], + expires=past_date, + write_enabled=False, ), ) - Token.objects.bulk_create(tokens) + for token in tokens: + token.save() def test_q(self): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_version(self): + params = {'version': 1} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'version': 2} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_user(self): users = User.objects.order_by('id')[:2] params = {'user_id': [users[0].pk, users[1].pk]} @@ -313,7 +331,7 @@ class TokenTestCase(TestCase, BaseFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_key(self): - tokens = Token.objects.all()[:2] + tokens = Token.objects.filter(version=2) params = {'key': [tokens[0].key, tokens[1].key]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/users/tests/test_views.py b/netbox/users/tests/test_views.py index e66c00d0a..0395c2209 100644 --- a/netbox/users/tests/test_views.py +++ b/netbox/users/tests/test_views.py @@ -215,6 +215,7 @@ class TokenTestCase( ): model = Token maxDiff = None + validation_excluded_fields = ['token', 'user'] @classmethod def setUpTestData(cls): @@ -223,32 +224,34 @@ class TokenTestCase( create_test_user('User 2'), ) tokens = ( - Token(key='123456789012345678901234567890123456789A', user=users[0]), - Token(key='123456789012345678901234567890123456789B', user=users[0]), - Token(key='123456789012345678901234567890123456789C', user=users[1]), + Token(user=users[0]), + Token(user=users[0]), + Token(user=users[1]), ) - Token.objects.bulk_create(tokens) + for token in tokens: + token.save() cls.form_data = { + 'version': 2, + 'token': '4F9DAouzURLbicyoG55htImgqQ0b4UZHP5LUYgl5', 'user': users[0].pk, - 'key': '1234567890123456789012345678901234567890', - 'description': 'testdescription', + 'description': 'Test token', } cls.csv_data = ( - "key,user,description", - f"123456789012345678901234567890123456789D,{users[0].pk},testdescriptionD", - f"123456789012345678901234567890123456789E,{users[1].pk},testdescriptionE", - f"123456789012345678901234567890123456789F,{users[1].pk},testdescriptionF", + "token,user,description", + f"123456789012345678901234567890123456789A,{users[0].pk},Test token", + f"123456789012345678901234567890123456789B,{users[1].pk},Test token", + f"123456789012345678901234567890123456789C,{users[1].pk},Test token", ) cls.csv_update_data = ( "id,description", - f"{tokens[0].pk},testdescriptionH", - f"{tokens[1].pk},testdescriptionI", - f"{tokens[2].pk},testdescriptionJ", + f"{tokens[0].pk},New description", + f"{tokens[1].pk},New description", + f"{tokens[2].pk},New description", ) cls.bulk_edit_data = { - 'description': 'newdescription', + 'description': 'New description', } diff --git a/netbox/users/utils.py b/netbox/users/utils.py index 114d8ab6d..045d192c7 100644 --- a/netbox/users/utils.py +++ b/netbox/users/utils.py @@ -1,5 +1,12 @@ +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured from social_core.storage import NO_ASCII_REGEX, NO_SPECIAL_REGEX +__all__ = ( + 'clean_username', + 'get_current_pepper', +) + def clean_username(value): """Clean username removing any unsupported character""" @@ -7,3 +14,13 @@ def clean_username(value): value = NO_SPECIAL_REGEX.sub('', value) value = value.replace(':', '') return value + + +def get_current_pepper(): + """ + Return the ID and value of the newest (highest ID) cryptographic pepper. + """ + if len(settings.API_TOKEN_PEPPERS) < 1: + raise ImproperlyConfigured("Must define API_TOKEN_PEPPERS to use v2 API tokens") + newest_id = sorted(settings.API_TOKEN_PEPPERS)[-1] + return newest_id, settings.API_TOKEN_PEPPERS[newest_id] diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index 1fe881367..32e5fe53f 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -49,8 +49,8 @@ class APITestCase(ModelTestCase): # Create the test user and assign permissions self.user = User.objects.create_user(username='testuser') self.add_permissions(*self.user_permissions) - self.token = Token.objects.create(user=self.user) - self.header = {'HTTP_AUTHORIZATION': f'Token {self.token.key}'} + self.token = Token.objects.create(version=1, user=self.user) + self.header = {'HTTP_AUTHORIZATION': f'Token {self.token.plaintext}'} def _get_view_namespace(self): return f'{self.view_namespace or self.model._meta.app_label}-api' @@ -153,6 +153,7 @@ class APIViewTestCases: url = f'{self._get_list_url()}?brief=1' response = self.client.get(url, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(len(response.data['results']), self._get_queryset().count()) self.assertEqual(sorted(response.data['results'][0]), self.brief_fields) diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index f00b21d08..c054dc5a2 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -240,10 +240,12 @@ class ViewTestCases: :form_data: Data to be used when updating the first existing object. """ form_data = {} + form_edit_data = {} validation_excluded_fields = [] def test_edit_object_without_permission(self): instance = self._get_queryset().first() + form_data = self.form_edit_data or self.form_data # Try GET without permission with disable_warnings('django.request'): @@ -252,7 +254,7 @@ class ViewTestCases: # Try POST without permission request = { 'path': self._get_url('edit', instance), - 'data': post_data(self.form_data), + 'data': post_data(form_data), } with disable_warnings('django.request'): self.assertHttpStatus(self.client.post(**request), 403) @@ -260,6 +262,7 @@ class ViewTestCases: @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[]) def test_edit_object_with_permission(self): instance = self._get_queryset().first() + form_data = self.form_edit_data or self.form_data # Assign model-level permission obj_perm = ObjectPermission( @@ -275,21 +278,21 @@ class ViewTestCases: # Add custom field data if the model supports it if issubclass(self.model, CustomFieldsMixin): - add_custom_field_data(self.form_data, self.model) + add_custom_field_data(form_data, self.model) # If supported, add a changelog message if issubclass(self.model, ChangeLoggingMixin): - if 'changelog_message' not in self.form_data: - self.form_data['changelog_message'] = get_random_string(10) + if 'changelog_message' not in form_data: + form_data['changelog_message'] = get_random_string(10) # Try POST with model-level permission request = { 'path': self._get_url('edit', instance), - 'data': post_data(self.form_data), + 'data': post_data(form_data), } self.assertHttpStatus(self.client.post(**request), 302) instance = self._get_queryset().get(pk=instance.pk) - self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields) + self.assertInstanceEqual(instance, form_data, exclude=self.validation_excluded_fields) # Verify ObjectChange creation if issubclass(self.model, ChangeLoggingMixin): @@ -299,11 +302,12 @@ class ViewTestCases: ) self.assertEqual(len(objectchanges), 1) self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_UPDATE) - self.assertEqual(objectchanges[0].message, self.form_data['changelog_message']) + self.assertEqual(objectchanges[0].message, form_data['changelog_message']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[]) def test_edit_object_with_constrained_permission(self): instance1, instance2 = self._get_queryset().all()[:2] + form_data = self.form_edit_data or self.form_data # Assign constrained permission obj_perm = ObjectPermission( @@ -324,16 +328,16 @@ class ViewTestCases: # Try to edit a permitted object request = { 'path': self._get_url('edit', instance1), - 'data': post_data(self.form_data), + 'data': post_data(form_data), } self.assertHttpStatus(self.client.post(**request), 302) instance = self._get_queryset().get(pk=instance1.pk) - self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields) + self.assertInstanceEqual(instance, form_data, exclude=self.validation_excluded_fields) # Try to edit a non-permitted object request = { 'path': self._get_url('edit', instance2), - 'data': post_data(self.form_data), + 'data': post_data(form_data), } self.assertHttpStatus(self.client.post(**request), 404) From 5dc48f3a8895f7c1e5a359d0075bd309e89dd248 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 2 Oct 2025 15:26:22 -0400 Subject: [PATCH 013/125] Enforce a fixed key length for v2 tokens --- netbox/users/constants.py | 3 +++ .../users/migrations/0014_users_token_v2.py | 12 +++++++++++- netbox/users/models/tokens.py | 19 +++++++++---------- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/netbox/users/constants.py b/netbox/users/constants.py index b02c482e0..647249179 100644 --- a/netbox/users/constants.py +++ b/netbox/users/constants.py @@ -10,4 +10,7 @@ OBJECTPERMISSION_OBJECT_TYPES = Q( CONSTRAINT_TOKEN_USER = '$user' +# API tokens +TOKEN_KEY_LENGTH = 16 +TOKEN_DEFAULT_LENGTH = 40 TOKEN_CHARSET = string.ascii_letters + string.digits diff --git a/netbox/users/migrations/0014_users_token_v2.py b/netbox/users/migrations/0014_users_token_v2.py index 9e18e4a72..39f1cbf48 100644 --- a/netbox/users/migrations/0014_users_token_v2.py +++ b/netbox/users/migrations/0014_users_token_v2.py @@ -21,6 +21,7 @@ class Migration(migrations.Migration): migrations.RunSQL( sql="ALTER INDEX IF EXISTS users_token_key_key RENAME TO users_token_plaintext_key", ), + # Make plaintext (formerly key) nullable for v2 tokens migrations.AlterField( model_name='token', @@ -33,6 +34,7 @@ class Migration(migrations.Migration): validators=[django.core.validators.MinLengthValidator(40)] ), ), + # Add version field to distinguish v1 and v2 tokens migrations.AddField( model_name='token', @@ -40,17 +42,25 @@ class Migration(migrations.Migration): field=models.PositiveSmallIntegerField(default=1), # Mark all existing Tokens as v1 preserve_default=False, ), + # Change the default version for new tokens to v2 migrations.AlterField( model_name='token', name='version', field=models.PositiveSmallIntegerField(default=2), ), + # Add new key, pepper, and hmac_digest fields for v2 tokens migrations.AddField( model_name='token', name='key', - field=models.CharField(blank=True, max_length=16, null=True, unique=True), + field=models.CharField( + blank=True, + max_length=16, + null=True, + unique=True, + validators=[django.core.validators.MinLengthValidator(16)] + ), ), migrations.AddField( model_name='token', diff --git a/netbox/users/models/tokens.py b/netbox/users/models/tokens.py index cf35c4e6a..96aa8e821 100644 --- a/netbox/users/models/tokens.py +++ b/netbox/users/models/tokens.py @@ -1,8 +1,6 @@ -import binascii import hashlib import hmac import random -import os from django.conf import settings from django.contrib.postgres.fields import ArrayField @@ -16,7 +14,7 @@ from netaddr import IPNetwork from ipam.fields import IPNetworkField from users.choices import TokenVersionChoices -from users.constants import TOKEN_CHARSET +from users.constants import TOKEN_CHARSET, TOKEN_DEFAULT_LENGTH, TOKEN_KEY_LENGTH from users.utils import get_current_pepper from utilities.querysets import RestrictedQuerySet @@ -75,10 +73,11 @@ class Token(models.Model): ) key = models.CharField( verbose_name=_('key'), - max_length=16, + max_length=TOKEN_KEY_LENGTH, unique=True, blank=True, null=True, + validators=[MinLengthValidator(TOKEN_KEY_LENGTH)], help_text=_('v2 token identification key'), ) pepper = models.PositiveSmallIntegerField( @@ -148,7 +147,7 @@ class Token(models.Model): if self.v1: self.plaintext = value elif self.v2: - self.key = self.key or self.generate(16) + self.key = self.key or self.generate_key() self.update_digest() def clean(self): @@ -162,15 +161,15 @@ class Token(models.Model): return super().save(*args, **kwargs) - @staticmethod - def generate_key(): + @classmethod + def generate_key(cls): """ - DEPRECATED: Generate and return a random 160-bit key expressed in hexadecimal. + Generate and return a random alphanumeric key for v2 tokens. """ - return binascii.hexlify(os.urandom(20)).decode() + return cls.generate(length=TOKEN_KEY_LENGTH) @staticmethod - def generate(length=40): + def generate(length=TOKEN_DEFAULT_LENGTH): """ Generate and return a random token value of the given length. """ From 11099b01bb3ba4ed80736afac6fab62c5ca31cf7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 2 Oct 2025 15:38:17 -0400 Subject: [PATCH 014/125] Rename pepper field to pepper_id for clarity --- contrib/openapi.json | 20 +++++++++---------- netbox/account/tables.py | 4 ++-- netbox/templates/users/token.html | 4 ++-- netbox/users/api/serializers_/tokens.py | 2 +- netbox/users/filtersets.py | 2 +- .../users/migrations/0014_users_token_v2.py | 2 +- netbox/users/models/tokens.py | 8 ++++---- netbox/users/tables.py | 2 +- 8 files changed, 22 insertions(+), 22 deletions(-) diff --git a/contrib/openapi.json b/contrib/openapi.json index 3618a36af..7fda93ac5 100644 --- a/contrib/openapi.json +++ b/contrib/openapi.json @@ -166113,7 +166113,7 @@ }, { "in": "query", - "name": "pepper", + "name": "pepper_id", "schema": { "type": "array", "items": { @@ -166126,14 +166126,14 @@ }, { "in": "query", - "name": "pepper__empty", + "name": "pepper_id__empty", "schema": { "type": "boolean" } }, { "in": "query", - "name": "pepper__gt", + "name": "pepper_id__gt", "schema": { "type": "array", "items": { @@ -166146,7 +166146,7 @@ }, { "in": "query", - "name": "pepper__gte", + "name": "pepper_id__gte", "schema": { "type": "array", "items": { @@ -166159,7 +166159,7 @@ }, { "in": "query", - "name": "pepper__lt", + "name": "pepper_id__lt", "schema": { "type": "array", "items": { @@ -166172,7 +166172,7 @@ }, { "in": "query", - "name": "pepper__lte", + "name": "pepper_id__lte", "schema": { "type": "array", "items": { @@ -166185,7 +166185,7 @@ }, { "in": "query", - "name": "pepper__n", + "name": "pepper_id__n", "schema": { "type": "array", "items": { @@ -228205,7 +228205,7 @@ "type": "boolean", "description": "Permit create/update/delete operations using this key" }, - "pepper": { + "pepper_id": { "type": "integer", "maximum": 32767, "minimum": 0, @@ -244459,7 +244459,7 @@ "type": "boolean", "description": "Permit create/update/delete operations using this key" }, - "pepper": { + "pepper_id": { "type": "integer", "maximum": 32767, "minimum": 0, @@ -244654,7 +244654,7 @@ "type": "boolean", "description": "Permit create/update/delete operations using this key" }, - "pepper": { + "pepper_id": { "type": "integer", "maximum": 32767, "minimum": 0, diff --git a/netbox/account/tables.py b/netbox/account/tables.py index 0b15a8a13..02dce8bdc 100644 --- a/netbox/account/tables.py +++ b/netbox/account/tables.py @@ -53,6 +53,6 @@ class UserTokenTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = UserToken fields = ( - 'pk', 'id', 'version', 'key', 'pepper', 'description', 'write_enabled', 'created', 'expires', 'last_used', - 'allowed_ips', + 'pk', 'id', 'version', 'key', 'pepper_id', 'description', 'write_enabled', 'created', 'expires', + 'last_used', 'allowed_ips', ) diff --git a/netbox/templates/users/token.html b/netbox/templates/users/token.html index 039d02759..86e96a6f3 100644 --- a/netbox/templates/users/token.html +++ b/netbox/templates/users/token.html @@ -28,8 +28,8 @@ {{ object }}
    {% trans "Pepper" %}{{ object.pepper }}{% trans "Pepper ID" %}{{ object.pepper_id }}
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    {% trans "Key" %} - {% if key %} -
    - {% copy_content "token_id" %} -
    -
    {{ key }}
    - {% else %} - {{ object.partial }} - {% endif %} -
    {% trans "Description" %}{{ object.description|placeholder }}
    {% trans "Write enabled" %}{% checkmark object.write_enabled %}
    {% trans "Created" %}{{ object.created|isodatetime }}
    {% trans "Expires" %}{{ object.expires|isodatetime|placeholder }}
    {% trans "Last used" %}{{ object.last_used|isodatetime|placeholder }}
    {% trans "Allowed IPs" %}{{ object.allowed_ips|join:", "|placeholder }}
    -
- - -{% endblock %} diff --git a/netbox/templates/users/token.html b/netbox/templates/users/token.html index 86e96a6f3..b3eb80b87 100644 --- a/netbox/templates/users/token.html +++ b/netbox/templates/users/token.html @@ -20,7 +20,14 @@ {% if object.version == 1 %} {% trans "Token" %} - {{ object.partial }} + + {% if settings.ALLOW_TOKEN_RETRIEVAL %} + {{ object.plaintext }} + + {% else %} + {{ object.partial }} + {% endif %} + {% else %} From d69042f26e31bcc6832aac2e6090f1b2e2cc0090 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 3 Oct 2025 09:53:44 -0400 Subject: [PATCH 019/125] Clean up token tables --- netbox/account/tables.py | 58 --------------------------------- netbox/account/views.py | 6 ++-- netbox/netbox/tables/columns.py | 2 +- netbox/users/tables.py | 39 ++++++++++++++++++++-- 4 files changed, 41 insertions(+), 64 deletions(-) delete mode 100644 netbox/account/tables.py diff --git a/netbox/account/tables.py b/netbox/account/tables.py deleted file mode 100644 index 02dce8bdc..000000000 --- a/netbox/account/tables.py +++ /dev/null @@ -1,58 +0,0 @@ -from django.utils.translation import gettext as _ - -from account.models import UserToken -from netbox.tables import NetBoxTable, columns - -__all__ = ( - 'UserTokenTable', -) - - -TOKEN = """{{ record }}""" - -ALLOWED_IPS = """{{ value|join:", " }}""" - -COPY_BUTTON = """ -{% if settings.ALLOW_TOKEN_RETRIEVAL %} - {% copy_content record.pk prefix="token_" color="success" %} -{% endif %} -""" - - -class UserTokenTable(NetBoxTable): - """ - Table for users to manager their own API tokens under account views. - """ - key = columns.TemplateColumn( - verbose_name=_('Key'), - template_code=TOKEN, - ) - write_enabled = columns.BooleanColumn( - verbose_name=_('Write Enabled') - ) - created = columns.DateTimeColumn( - timespec='minutes', - verbose_name=_('Created'), - ) - expires = columns.DateTimeColumn( - timespec='minutes', - verbose_name=_('Expires'), - ) - last_used = columns.DateTimeColumn( - verbose_name=_('Last Used'), - ) - allowed_ips = columns.TemplateColumn( - verbose_name=_('Allowed IPs'), - template_code=ALLOWED_IPS - ) - actions = columns.ActionsColumn( - actions=('edit', 'delete'), - extra_buttons=COPY_BUTTON - ) - - class Meta(NetBoxTable.Meta): - model = UserToken - fields = ( - 'pk', 'id', 'version', 'key', 'pepper_id', 'description', 'write_enabled', 'created', 'expires', - 'last_used', 'allowed_ips', - ) diff --git a/netbox/account/views.py b/netbox/account/views.py index b513f04e4..2b1d64fe3 100644 --- a/netbox/account/views.py +++ b/netbox/account/views.py @@ -26,8 +26,9 @@ from extras.tables import BookmarkTable, NotificationTable, SubscriptionTable from netbox.authentication import get_auth_backend_display, get_saml_idps from netbox.config import get_config from netbox.views import generic -from users import forms, tables +from users import forms from users.models import UserConfig +from users.tables import TokenTable from utilities.request import safe_for_redirect from utilities.string import remove_linebreaks from utilities.views import register_model_view @@ -328,7 +329,8 @@ class UserTokenListView(LoginRequiredMixin, View): def get(self, request): tokens = UserToken.objects.filter(user=request.user) - table = tables.UserTokenTable(tokens) + table = TokenTable(tokens) + table.columns.hide('user') table.configure(request) return render(request, 'account/token_list.html', { diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index f480c2085..12b781cf4 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -270,7 +270,7 @@ class ActionsColumn(tables.Column): if not (self.actions or self.extra_buttons): return '' # Skip dummy records (e.g. available VLANs or IP ranges replacing individual IPs) - if type(record) is not model or not getattr(record, 'pk', None): + if not isinstance(record, model) or not getattr(record, 'pk', None): return '' if request := getattr(table, 'context', {}).get('request'): diff --git a/netbox/users/tables.py b/netbox/users/tables.py index 249803840..c4b561164 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -1,7 +1,6 @@ import django_tables2 as tables from django.utils.translation import gettext as _ -from account.tables import UserTokenTable from netbox.tables import NetBoxTable, columns from users.models import Group, ObjectPermission, Token, User @@ -12,19 +11,53 @@ __all__ = ( 'UserTable', ) +TOKEN = """{{ record }}""" -class TokenTable(UserTokenTable): +COPY_BUTTON = """ +{% if settings.ALLOW_TOKEN_RETRIEVAL %} + {% copy_content record.pk prefix="token_" color="success" %} +{% endif %} +""" + + +class TokenTable(NetBoxTable): user = tables.Column( linkify=True, verbose_name=_('User') ) + token = columns.TemplateColumn( + verbose_name=_('token'), + template_code=TOKEN, + ) + write_enabled = columns.BooleanColumn( + verbose_name=_('Write Enabled') + ) + created = columns.DateTimeColumn( + timespec='minutes', + verbose_name=_('Created'), + ) + expires = columns.DateTimeColumn( + timespec='minutes', + verbose_name=_('Expires'), + ) + last_used = columns.DateTimeColumn( + verbose_name=_('Last Used'), + ) + allowed_ips = columns.ArrayColumn( + verbose_name=_('Allowed IPs'), + ) + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + extra_buttons=COPY_BUTTON + ) class Meta(NetBoxTable.Meta): model = Token fields = ( - 'pk', 'id', 'version', 'key', 'pepper_id', 'user', 'description', 'write_enabled', 'created', 'expires', + 'pk', 'id', 'token', 'version', 'pepper_id', 'user', 'description', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips', ) + default_columns = ('token', 'version', 'user', 'write_enabled', 'description', 'allowed_ips') class UserTable(NetBoxTable): From a54c508da29c5532b3cf4299eee9746f9e987114 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 3 Oct 2025 09:58:20 -0400 Subject: [PATCH 020/125] Misc cleanup --- netbox/account/views.py | 2 -- netbox/users/tables.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/netbox/account/views.py b/netbox/account/views.py index 2b1d64fe3..da4aa6d74 100644 --- a/netbox/account/views.py +++ b/netbox/account/views.py @@ -345,11 +345,9 @@ class UserTokenView(LoginRequiredMixin, View): def get(self, request, pk): token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk) - key = token.key if token.v2 or settings.ALLOW_TOKEN_RETRIEVAL else None return render(request, 'account/token.html', { 'object': token, - 'key': key, }) diff --git a/netbox/users/tables.py b/netbox/users/tables.py index c4b561164..c5207d899 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -11,7 +11,7 @@ __all__ = ( 'UserTable', ) -TOKEN = """{{ record }}""" +TOKEN = """{{ record }}""" COPY_BUTTON = """ {% if settings.ALLOW_TOKEN_RETRIEVAL %} From ac335c3d879f33a7197edefb63930667919350f5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 3 Oct 2025 10:23:00 -0400 Subject: [PATCH 021/125] Clean up filterset tests --- netbox/netbox/configuration_testing.py | 2 +- netbox/users/filtersets.py | 14 +++++++++++++- netbox/users/tests/test_filtersets.py | 14 +++++++++----- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/netbox/netbox/configuration_testing.py b/netbox/netbox/configuration_testing.py index bbe6dbada..36f9d7338 100644 --- a/netbox/netbox/configuration_testing.py +++ b/netbox/netbox/configuration_testing.py @@ -46,7 +46,7 @@ DEFAULT_PERMISSIONS = {} ALLOW_TOKEN_RETRIEVAL = True API_TOKEN_PEPPERS = { - 0: 'TEST-VALUE-DO-NOT-USE', + 1: 'TEST-VALUE-DO-NOT-USE', } LOGGING = { diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index a67761354..36fbdcb0d 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -130,15 +130,27 @@ class TokenFilterSet(BaseFilterSet): field_name='expires', lookup_expr='lte' ) + last_used = django_filters.DateTimeFilter() + last_used__gte = django_filters.DateTimeFilter( + field_name='last_used', + lookup_expr='gte' + ) + last_used__lte = django_filters.DateTimeFilter( + field_name='last_used', + lookup_expr='lte' + ) class Meta: model = Token - fields = ('id', 'version', 'key', 'pepper_id', 'write_enabled', 'description', 'last_used') + fields = ( + 'id', 'version', 'key', 'pepper_id', 'write_enabled', 'description', 'created', 'expires', 'last_used', + ) def search(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter( + Q(key=value) | Q(user__username__icontains=value) | Q(description__icontains=value) ) diff --git a/netbox/users/tests/test_filtersets.py b/netbox/users/tests/test_filtersets.py index f7404cedd..1f7336cc3 100644 --- a/netbox/users/tests/test_filtersets.py +++ b/netbox/users/tests/test_filtersets.py @@ -315,6 +315,15 @@ class TokenTestCase(TestCase, BaseFilterSetTests): params = {'version': 2} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_key(self): + tokens = Token.objects.filter(version=2) + params = {'key': [tokens[0].key, tokens[1].key]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_pepper_id(self): + params = {'pepper_id': [1]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_user(self): users = User.objects.order_by('id')[:2] params = {'user_id': [users[0].pk, users[1].pk]} @@ -330,11 +339,6 @@ class TokenTestCase(TestCase, BaseFilterSetTests): params = {'expires__lte': '2021-01-01T00:00:00'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - def test_key(self): - tokens = Token.objects.filter(version=2) - params = {'key': [tokens[0].key, tokens[1].key]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_write_enabled(self): params = {'write_enabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) From 6388705e57a94be300e7970bc6070ba03ed5f347 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 3 Oct 2025 10:45:54 -0400 Subject: [PATCH 022/125] Clean up TokenForm --- netbox/users/forms/filtersets.py | 3 ++- netbox/users/forms/model_forms.py | 18 +++++------------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/netbox/users/forms/filtersets.py b/netbox/users/forms/filtersets.py index 96f5a48d2..32e52b5f9 100644 --- a/netbox/users/forms/filtersets.py +++ b/netbox/users/forms/filtersets.py @@ -8,6 +8,7 @@ from users.models import Group, ObjectPermission, Token, User from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm from utilities.forms.fields import DynamicModelMultipleChoiceField from utilities.forms.rendering import FieldSet +from utilities.forms.utils import add_blank_choice from utilities.forms.widgets import DateTimePicker __all__ = ( @@ -114,7 +115,7 @@ class TokenFilterForm(SavedFiltersMixin, FilterForm): FieldSet('version', 'user_id', 'write_enabled', 'expires', 'last_used', name=_('Token')), ) version = forms.ChoiceField( - choices=TokenVersionChoices, + choices=add_blank_choice(TokenVersionChoices), required=False, ) user_id = DynamicModelMultipleChoiceField( diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index 9b6c8aaba..582062ebb 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -16,11 +16,7 @@ from users.choices import TokenVersionChoices from users.constants import * from users.models import * from utilities.data import flatten_dict -from utilities.forms.fields import ( - ContentTypeMultipleChoiceField, - DynamicModelMultipleChoiceField, - JSONField, -) +from utilities.forms.fields import ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField from utilities.forms.rendering import FieldSet from utilities.forms.widgets import DateTimePicker, SplitMultiSelectWidget from utilities.permissions import qs_filter_from_constraints @@ -155,17 +151,17 @@ class UserTokenForm(forms.ModelForm): # Omit the key field when editing an existing token if token retrieval is not permitted if self.instance.v1 and settings.ALLOW_TOKEN_RETRIEVAL: - self.fields['token'].initial = self.instance.key + self.initial['token'] = self.instance.plaintext else: del self.fields['token'] # Generate an initial random key if none has been specified - if self.instance._state.adding and not self.initial.get('token'): + elif self.instance._state.adding and not self.initial.get('token'): self.initial['version'] = TokenVersionChoices.V2 self.initial['token'] = Token.generate() def save(self, commit=True): - if self.cleaned_data.get('token'): + if self.instance._state.adding and self.cleaned_data.get('token'): self.instance.token = self.cleaned_data['token'] return super().save(commit=commit) @@ -177,14 +173,10 @@ class TokenForm(UserTokenForm): label=_('User') ) - class Meta: - model = Token + class Meta(UserTokenForm.Meta): fields = [ 'version', 'token', 'user', 'write_enabled', 'expires', 'description', 'allowed_ips', ] - widgets = { - 'expires': DateTimePicker(), - } class UserForm(forms.ModelForm): From 917a2c261873b1fedcc3d59a45553a67abc10645 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 3 Oct 2025 11:41:04 -0400 Subject: [PATCH 023/125] Validate peppers on init --- netbox/netbox/configuration_example.py | 10 ++++++++++ netbox/netbox/configuration_testing.py | 2 +- netbox/netbox/settings.py | 8 ++++---- netbox/utilities/security.py | 24 ++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 netbox/utilities/security.py diff --git a/netbox/netbox/configuration_example.py b/netbox/netbox/configuration_example.py index 612f75a40..18d30d29a 100644 --- a/netbox/netbox/configuration_example.py +++ b/netbox/netbox/configuration_example.py @@ -68,6 +68,16 @@ REDIS = { # https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-SECRET_KEY SECRET_KEY = '' +# Define a mapping of cryptographic peppers to use when hashing API tokens. A minimum of one pepper is required to +# enable v2 API tokens (NetBox v4.5+). Define peppers as a mapping of numeric ID to pepper value, as shown below. Each +# pepper must be at least 50 characters in length. +# +# API_TOKEN_PEPPERS = { +# 1: "", +# 2: "", +# } +API_TOKEN_PEPPERS = {} + ######################### # # diff --git a/netbox/netbox/configuration_testing.py b/netbox/netbox/configuration_testing.py index 36f9d7338..6d1de2008 100644 --- a/netbox/netbox/configuration_testing.py +++ b/netbox/netbox/configuration_testing.py @@ -46,7 +46,7 @@ DEFAULT_PERMISSIONS = {} ALLOW_TOKEN_RETRIEVAL = True API_TOKEN_PEPPERS = { - 1: 'TEST-VALUE-DO-NOT-USE', + 1: 'TEST-VALUE-DO-NOT-USE-TEST-VALUE-DO-NOT-USE-TEST-VALUE-DO-NOT-USE', } LOGGING = { diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index a912c2d6e..828f73109 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -19,6 +19,7 @@ from netbox.plugins import PluginConfig from netbox.registry import registry import storages.utils # type: ignore from utilities.release import load_release_data +from utilities.security import validate_peppers from utilities.string import trailing_slash # @@ -217,10 +218,9 @@ if len(SECRET_KEY) < 50: ) # Validate API token peppers -for key in API_TOKEN_PEPPERS: - if type(key) is not int: - raise ImproperlyConfigured(f"Invalid API_TOKEN_PEPPERS key: {key}. All keys must be integers.") -if not API_TOKEN_PEPPERS: +if API_TOKEN_PEPPERS: + validate_peppers(API_TOKEN_PEPPERS) +else: warnings.warn("API_TOKEN_PEPPERS is not defined. v2 API tokens cannot be used.") # Validate update repo URL and timeout diff --git a/netbox/utilities/security.py b/netbox/utilities/security.py new file mode 100644 index 000000000..47a18d265 --- /dev/null +++ b/netbox/utilities/security.py @@ -0,0 +1,24 @@ +from django.core.exceptions import ImproperlyConfigured + +__all__ = ( + 'validate_peppers', +) + + +def validate_peppers(peppers): + """ + Validate the given dictionary of cryptographic peppers for type & sufficient length. + """ + if type(peppers) is not dict: + raise ImproperlyConfigured("API_TOKEN_PEPPERS must be a dictionary.") + for key, pepper in peppers.items(): + if type(key) is not int: + raise ImproperlyConfigured(f"Invalid API_TOKEN_PEPPERS key: {key}. All keys must be integers.") + if not 0 <= key <= 32767: + raise ImproperlyConfigured( + f"Invalid API_TOKEN_PEPPERS key: {key}. Key values must be between 0 and 32767, inclusive." + ) + if type(pepper) is not str: + raise ImproperlyConfigured(f"Invalid pepper {key}: Pepper value must be a string.") + if len(pepper) < 50: + raise ImproperlyConfigured(f"Invalid pepper {key}: Pepper must be at least 50 characters in length.") From 9b85d92ad0e066ee9b633415a38b04861fadf71c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 3 Oct 2025 12:08:24 -0400 Subject: [PATCH 024/125] Clean up auth backend --- contrib/openapi.json | 79 ++++------------------------- netbox/netbox/api/authentication.py | 33 ++++++++---- netbox/users/utils.py | 5 +- 3 files changed, 34 insertions(+), 83 deletions(-) diff --git a/contrib/openapi.json b/contrib/openapi.json index 7fda93ac5..810f9936d 100644 --- a/contrib/openapi.json +++ b/contrib/openapi.json @@ -166003,86 +166003,25 @@ "in": "query", "name": "last_used", "schema": { - "type": "array", - "items": { - "type": "string", - "format": "date-time" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "last_used__empty", - "schema": { - "type": "boolean" + "type": "string", + "format": "date-time" } }, - { - "in": "query", - "name": "last_used__gt", - "schema": { - "type": "array", - "items": { - "type": "string", - "format": "date-time" - } - }, - "explode": true, - "style": "form" - }, { "in": "query", "name": "last_used__gte", "schema": { - "type": "array", - "items": { - "type": "string", - "format": "date-time" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "last_used__lt", - "schema": { - "type": "array", - "items": { - "type": "string", - "format": "date-time" - } - }, - "explode": true, - "style": "form" + "type": "string", + "format": "date-time" + } }, { "in": "query", "name": "last_used__lte", "schema": { - "type": "array", - "items": { - "type": "string", - "format": "date-time" - } - }, - "explode": true, - "style": "form" - }, - { - "in": "query", - "name": "last_used__n", - "schema": { - "type": "array", - "items": { - "type": "string", - "format": "date-time" - } - }, - "explode": true, - "style": "form" + "type": "string", + "format": "date-time" + } }, { "name": "limit", @@ -256896,7 +256835,7 @@ "type": "apiKey", "in": "header", "name": "Authorization", - "description": "Set `Token ` (v1) or `Bearer ` (v2) in the Authorization header" + "description": "`Token ` (v1) or `Bearer .` (v2)" } } }, diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index 9c73259bf..27247169a 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -11,8 +11,8 @@ from netbox.config import get_config from users.models import Token from utilities.request import get_client_ip -V1_KEYWORD = 'token' -V2_KEYWORD = 'bearer' +V1_KEYWORD = 'Token' +V2_KEYWORD = 'Bearer' class TokenAuthentication(BaseAuthentication): @@ -22,26 +22,37 @@ class TokenAuthentication(BaseAuthentication): model = Token def authenticate(self, request): + # Ignore; Authorization header is not present if not (auth := get_authorization_header(request).split()): return - # Check for Token/Bearer keyword in HTTP header value & infer token version + # Infer token version from Token/Bearer keyword in HTTP header if auth[0].lower() == V1_KEYWORD.lower().encode(): version = 1 elif auth[0].lower() == V2_KEYWORD.lower().encode(): version = 2 else: + # Ignore; unrecognized header value return - # Extract token key from authorization header + # Extract token from authorization header. This should be in one of the following two forms: + # * Authorization: Token (v1) + # * Authorization: Bearer . (v2) if len(auth) != 2: - raise exceptions.AuthenticationFailed("Invalid authorization header: Error parsing token") + if version == 1: + raise exceptions.AuthenticationFailed( + 'Invalid authorization header: Must be in the form "Token "' + ) + else: + raise exceptions.AuthenticationFailed( + 'Invalid authorization header: Must be in the form "Bearer ."' + ) + + # Extract the key (if v2) & token plaintext from the auth header try: auth_value = auth[1].decode() except UnicodeError: raise exceptions.AuthenticationFailed("Invalid authorization header: Token contains invalid characters") - - # Look for a matching token in the database if version == 1: key, plaintext = None, auth_value else: @@ -52,6 +63,8 @@ class TokenAuthentication(BaseAuthentication): "Invalid authorization header: Could not parse key from v2 token. Did you mean to use 'Token' " "instead of 'Bearer'?" ) + + # Look for a matching token in the database try: qs = Token.objects.prefetch_related('user') if version == 1: @@ -61,8 +74,8 @@ class TokenAuthentication(BaseAuthentication): # Fetch v2 token by key, then validate the plaintext token = qs.get(version=version, key=key) if not token.validate(plaintext): - # TODO: Consider security implications of enabling validation of token key without valid plaintext - raise exceptions.AuthenticationFailed(f"Validation failed for v2 token {key}") + # Key is valid but plaintext is not. Raise DoesNotExist to guard against key enumeration. + raise Token.DoesNotExist() except Token.DoesNotExist: raise exceptions.AuthenticationFailed(f"Invalid v{version} token") @@ -180,5 +193,5 @@ class TokenScheme(OpenApiAuthenticationExtension): 'type': 'apiKey', 'in': 'header', 'name': 'Authorization', - 'description': 'Set `Token ` (v1) or `Bearer ` (v2) in the Authorization header', + 'description': '`Token ` (v1) or `Bearer .` (v2)', } diff --git a/netbox/users/utils.py b/netbox/users/utils.py index 5db8cb65e..c355873a8 100644 --- a/netbox/users/utils.py +++ b/netbox/users/utils.py @@ -1,5 +1,4 @@ from django.conf import settings -from django.core.exceptions import ImproperlyConfigured from social_core.storage import NO_ASCII_REGEX, NO_SPECIAL_REGEX __all__ = ( @@ -20,7 +19,7 @@ def get_current_pepper(): """ Return the ID and value of the newest (highest ID) cryptographic pepper. """ - if len(settings.API_TOKEN_PEPPERS) < 1: - raise ImproperlyConfigured("Must define API_TOKEN_PEPPERS to use v2 API tokens") + if not settings.API_TOKEN_PEPPERS: + raise ValueError("API_TOKEN_PEPPERS is not defined") newest_id = sorted(settings.API_TOKEN_PEPPERS.keys())[-1] return newest_id, settings.API_TOKEN_PEPPERS[newest_id] From bb75bceec54560000a62815dbe4616fb0c10eeac Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 3 Oct 2025 13:55:48 -0400 Subject: [PATCH 025/125] Clean up tests --- netbox/netbox/tests/test_authentication.py | 40 +++++++++++++++------- netbox/users/tests/test_api.py | 3 +- netbox/users/tests/test_views.py | 6 ++-- netbox/utilities/testing/views.py | 24 ++++++------- 4 files changed, 43 insertions(+), 30 deletions(-) diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index bd7d41186..e33e72f5d 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -94,30 +94,46 @@ class TokenAuthenticationTestCase(APITestCase): @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) def test_token_write_enabled(self): url = reverse('dcim-api:site-list') - data = { - 'name': 'Site 1', - 'slug': 'site-1', - } + data = [ + { + 'name': 'Site 1', + 'slug': 'site-1', + }, + { + 'name': 'Site 2', + 'slug': 'site-2', + }, + ] + self.add_permissions('dcim.view_site', 'dcim.add_site') # Create v1 & v2 tokens token1 = Token.objects.create(version=1, user=self.user, write_enabled=False) token2 = Token.objects.create(version=2, user=self.user, write_enabled=False) - # Request with a write-disabled token should fail - response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Token {token1.token}') + token1_header = f'Token {token1.token}' + token2_header = f'Bearer {token2.key}.{token2.token}' + + # GET request with a write-disabled token should succeed + response = self.client.get(url, HTTP_AUTHORIZATION=token1_header) + self.assertEqual(response.status_code, 200) + response = self.client.get(url, HTTP_AUTHORIZATION=token2_header) + self.assertEqual(response.status_code, 200) + + # POST request with a write-disabled token should fail + response = self.client.post(url, data[0], format='json', HTTP_AUTHORIZATION=token1_header) self.assertEqual(response.status_code, 403) - response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Bearer {token2.key}.{token2.token}') + response = self.client.post(url, data[1], format='json', HTTP_AUTHORIZATION=token2_header) self.assertEqual(response.status_code, 403) - # Request with a write-enabled token should succeed + # POST request with a write-enabled token should succeed token1.write_enabled = True token1.save() token2.write_enabled = True token2.save() - response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Token {token1.token}') - self.assertEqual(response.status_code, 403) - response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Bearer {token2.key}.{token2.token}') - self.assertEqual(response.status_code, 403) + response = self.client.post(url, data[0], format='json', HTTP_AUTHORIZATION=token1_header) + self.assertEqual(response.status_code, 201) + response = self.client.post(url, data[1], format='json', HTTP_AUTHORIZATION=token2_header) + self.assertEqual(response.status_code, 201) @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) def test_token_allowed_ips(self): diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py index f0218179a..741c578b6 100644 --- a/netbox/users/tests/test_api.py +++ b/netbox/users/tests/test_api.py @@ -2,6 +2,7 @@ from django.test import override_settings from django.urls import reverse from core.models import ObjectType +from users.constants import TOKEN_DEFAULT_LENGTH from users.models import Group, ObjectPermission, Token, User from utilities.data import deepmerge from utilities.testing import APIViewTestCases, APITestCase, create_test_user @@ -257,7 +258,7 @@ class TokenTest( response = self.client.post(url, data, format='json', **self.header) self.assertEqual(response.status_code, 201) self.assertIn('token', response.data) - self.assertEqual(len(response.data['token']), 40) + self.assertEqual(len(response.data['token']), TOKEN_DEFAULT_LENGTH) self.assertEqual(response.data['description'], data['description']) self.assertEqual(response.data['expires'], data['expires']) token = Token.objects.get(user=user) diff --git a/netbox/users/tests/test_views.py b/netbox/users/tests/test_views.py index 0395c2209..24aec6941 100644 --- a/netbox/users/tests/test_views.py +++ b/netbox/users/tests/test_views.py @@ -240,9 +240,9 @@ class TokenTestCase( cls.csv_data = ( "token,user,description", - f"123456789012345678901234567890123456789A,{users[0].pk},Test token", - f"123456789012345678901234567890123456789B,{users[1].pk},Test token", - f"123456789012345678901234567890123456789C,{users[1].pk},Test token", + f"zjebxBPzICiPbWz0Wtx0fTL7bCKXKGTYhNzkgC2S,{users[0].pk},Test token", + f"9Z5kGtQWba60Vm226dPDfEAV6BhlTr7H5hAXAfbF,{users[1].pk},Test token", + f"njpMnNT6r0k0MDccoUhTYYlvP9BvV3qLzYN2p6Uu,{users[1].pk},Test token", ) cls.csv_update_data = ( diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index c054dc5a2..f00b21d08 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -240,12 +240,10 @@ class ViewTestCases: :form_data: Data to be used when updating the first existing object. """ form_data = {} - form_edit_data = {} validation_excluded_fields = [] def test_edit_object_without_permission(self): instance = self._get_queryset().first() - form_data = self.form_edit_data or self.form_data # Try GET without permission with disable_warnings('django.request'): @@ -254,7 +252,7 @@ class ViewTestCases: # Try POST without permission request = { 'path': self._get_url('edit', instance), - 'data': post_data(form_data), + 'data': post_data(self.form_data), } with disable_warnings('django.request'): self.assertHttpStatus(self.client.post(**request), 403) @@ -262,7 +260,6 @@ class ViewTestCases: @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[]) def test_edit_object_with_permission(self): instance = self._get_queryset().first() - form_data = self.form_edit_data or self.form_data # Assign model-level permission obj_perm = ObjectPermission( @@ -278,21 +275,21 @@ class ViewTestCases: # Add custom field data if the model supports it if issubclass(self.model, CustomFieldsMixin): - add_custom_field_data(form_data, self.model) + add_custom_field_data(self.form_data, self.model) # If supported, add a changelog message if issubclass(self.model, ChangeLoggingMixin): - if 'changelog_message' not in form_data: - form_data['changelog_message'] = get_random_string(10) + if 'changelog_message' not in self.form_data: + self.form_data['changelog_message'] = get_random_string(10) # Try POST with model-level permission request = { 'path': self._get_url('edit', instance), - 'data': post_data(form_data), + 'data': post_data(self.form_data), } self.assertHttpStatus(self.client.post(**request), 302) instance = self._get_queryset().get(pk=instance.pk) - self.assertInstanceEqual(instance, form_data, exclude=self.validation_excluded_fields) + self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields) # Verify ObjectChange creation if issubclass(self.model, ChangeLoggingMixin): @@ -302,12 +299,11 @@ class ViewTestCases: ) self.assertEqual(len(objectchanges), 1) self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_UPDATE) - self.assertEqual(objectchanges[0].message, form_data['changelog_message']) + self.assertEqual(objectchanges[0].message, self.form_data['changelog_message']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[]) def test_edit_object_with_constrained_permission(self): instance1, instance2 = self._get_queryset().all()[:2] - form_data = self.form_edit_data or self.form_data # Assign constrained permission obj_perm = ObjectPermission( @@ -328,16 +324,16 @@ class ViewTestCases: # Try to edit a permitted object request = { 'path': self._get_url('edit', instance1), - 'data': post_data(form_data), + 'data': post_data(self.form_data), } self.assertHttpStatus(self.client.post(**request), 302) instance = self._get_queryset().get(pk=instance1.pk) - self.assertInstanceEqual(instance, form_data, exclude=self.validation_excluded_fields) + self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields) # Try to edit a non-permitted object request = { 'path': self._get_url('edit', instance2), - 'data': post_data(form_data), + 'data': post_data(self.form_data), } self.assertHttpStatus(self.client.post(**request), 404) From 82db8a9c02b18ca8a73e8982ce3653d94f7fc92e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 3 Oct 2025 14:24:21 -0400 Subject: [PATCH 026/125] Update documentation --- docs/configuration/required-parameters.md | 25 +++++++++++++++++++++++ docs/installation/3-netbox.md | 17 +++++++++++++++ docs/integrations/rest-api.md | 14 ++++++------- 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/docs/configuration/required-parameters.md b/docs/configuration/required-parameters.md index 19222740d..cced030b1 100644 --- a/docs/configuration/required-parameters.md +++ b/docs/configuration/required-parameters.md @@ -23,6 +23,31 @@ ALLOWED_HOSTS = ['*'] --- +## API_TOKEN_PEPPERS + +!!! info "This parameter was introduced in NetBox v4.5." + +[Cryptographic peppers](https://en.wikipedia.org/wiki/Pepper_(cryptography)) are employed to generate hashes of sensitive values on the server. This parameter defines the peppers used to hash v2 API tokens in NetBox. You must define at least one pepper before creating a v2 API token. See the [API documentation](../integrations/rest-api.md#authentication) for further information about how peppers are used. + +```python +API_TOKEN_PEPPERS = { + # DO NOT USE THIS EXAMPLE PEPPER IN PRODUCTION + 1: 'kp7ht*76fiQAhUi5dHfASLlYUE_S^gI^(7J^K5M!LfoH@vl&b_', +} +``` + +!!! 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. + +It is recommended to start with a pepper ID of `1`. Additional peppers can be introduced later as needed to begin rotating token hashes. + +!!! tip + Although NetBox will run without `API_TOKEN_PEPPERS` defined, the use of v2 API tokens will be unavailable. + +--- + ## DATABASE !!! warning "Legacy Configuration Parameter" diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index c192a3094..fd9b21f50 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -120,6 +120,23 @@ If you are not yet sure what the domain name and/or IP address of the NetBox ins ALLOWED_HOSTS = ['*'] ``` +### API_TOKEN_PEPPERS + +Define at least one random cryptographic pepper, identified by a numeric ID starting at 1. This will be used to generate SHA256 checksums for API tokens. + +```python +API_TOKEN_PEPPERS = { + # DO NOT USE THIS EXAMPLE PEPPER IN PRODUCTION + 1: 'kp7ht*76fiQAhUi5dHfASLlYUE_S^gI^(7J^K5M!LfoH@vl&b_', +} +``` + +!!! tip + As with [`SECRET_KEY`](#secret_key) below, you can use the `generate_secret_key.py` script to generate a random pepper: + ```no-highlight + python3 ../generate_secret_key.py + ``` + ### DATABASES This parameter holds the PostgreSQL database configuration details. The default database must be defined; additional databases may be defined as needed e.g. by plugins. diff --git a/docs/integrations/rest-api.md b/docs/integrations/rest-api.md index 9cecbca3d..6bc329d78 100644 --- a/docs/integrations/rest-api.md +++ b/docs/integrations/rest-api.md @@ -653,19 +653,17 @@ The NetBox REST API primarily employs token-based authentication. For convenienc ### Tokens -A token is a 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. +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. By default, all users can create and manage their own REST API tokens under the user control panel in the UI or via the REST API. This ability can be disabled by overriding the [`DEFAULT_PERMISSIONS`](../configuration/security.md#default_permissions) configuration parameter. -!!! info "Token Versions" - Beginning with NetBox v4.5, two types of API token are supported, denoted as v1 and v2. Users are strongly encouraged to create only v2 tokens, as these provide much stronger security than v1 tokens. Support for v1 tokens will be removed in a future NetBox release. - -When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation. - Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox. -!!! info "Restricting Token Retrieval" - The ability to retrieve the key value of a previously-created API token can be restricted by disabling the [`ALLOW_TOKEN_RETRIEVAL`](../configuration/security.md#allow_token_retrieval) configuration parameter. +#### v1 and v2 Tokens + +Beginning with NetBox v4.5, two versions of API token are supported, denoted as v1 and v2. Users are strongly encouraged to create only v2 tokens and to discontinue the use of v1 tokens. Support for v1 tokens will be removed in a future NetBox release. + +v2 API tokens offer much stronger security. The token plaintext given at creation time is hashed together with a configured [cryptographic pepper](../configuration/required-parameters.md#api_token_peppers) to generate a unique checksum. This checksum is irreversible; the token plaintext is never stored on the server and thus cannot be retrieved. #### Restricting Write Operations From c63e60a62b66ff0437c837741d9bb0047712bf4c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 6 Oct 2025 17:04:10 -0400 Subject: [PATCH 027/125] Add a token prefix --- contrib/openapi.json | 8 ++--- docs/features/api-integration.md | 2 +- docs/integrations/rest-api.md | 8 ++--- netbox/core/tests/test_api.py | 3 +- netbox/netbox/api/authentication.py | 35 +++++++------------ netbox/netbox/tests/test_authentication.py | 17 ++++----- netbox/users/constants.py | 3 +- .../users/migrations/0014_users_token_v2.py | 4 +-- netbox/users/models/tokens.py | 3 +- netbox/utilities/testing/api.py | 3 +- 10 files changed, 41 insertions(+), 45 deletions(-) diff --git a/contrib/openapi.json b/contrib/openapi.json index 810f9936d..f78bc4064 100644 --- a/contrib/openapi.json +++ b/contrib/openapi.json @@ -213929,7 +213929,7 @@ }, "mark_utilized": { "type": "boolean", - "description": "Report space as 100% utilized" + "description": "Report space as fully utilized" } }, "required": [ @@ -214038,7 +214038,7 @@ }, "mark_utilized": { "type": "boolean", - "description": "Report space as 100% utilized" + "description": "Report space as fully utilized" } }, "required": [ @@ -231032,7 +231032,7 @@ }, "mark_utilized": { "type": "boolean", - "description": "Report space as 100% utilized" + "description": "Report space as fully utilized" } } }, @@ -251418,7 +251418,7 @@ }, "mark_utilized": { "type": "boolean", - "description": "Report space as 100% utilized" + "description": "Report space as fully utilized" } }, "required": [ diff --git a/docs/features/api-integration.md b/docs/features/api-integration.md index 94a39d731..28aefda92 100644 --- a/docs/features/api-integration.md +++ b/docs/features/api-integration.md @@ -8,7 +8,7 @@ NetBox's REST API, powered by the [Django REST Framework](https://www.django-res ```no-highlight curl -s -X POST \ --H "Authorization: Token $TOKEN" \ +-H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ http://netbox/api/ipam/prefixes/ \ --data '{"prefix": "192.0.2.0/24", "site": {"name": "Branch 12"}}' diff --git a/docs/integrations/rest-api.md b/docs/integrations/rest-api.md index 6bc329d78..ed3eab316 100644 --- a/docs/integrations/rest-api.md +++ b/docs/integrations/rest-api.md @@ -682,13 +682,13 @@ It is possible to provision authentication tokens for other users via the REST A ### Authenticating to the API -An authentication token is included with a request in its `Authorization` header. The format of the header value depends on the version of token in use. v2 tokens use the following form, concatenating the token's key and plaintext value with a period: +An authentication token is included with a request in its `Authorization` header. The format of the header value depends on the version of token in use. v2 tokens use the following form, concatenating the token's prefix (`nbt_`) and key with its plaintext value, separated by a period: ``` -Authorization: Bearer . +Authorization: Bearer nbt_. ``` -v1 tokens use the prefix `Token` rather than `Bearer`, and include only the token plaintext. (v1 tokens do not have a key.) +Legacy v1 tokens use the prefix `Token` rather than `Bearer`, and include only the token plaintext. (v1 tokens do not have a key.) ``` Authorization: Token @@ -697,7 +697,7 @@ Authorization: Token Below is an example REST API request utilizing a v2 token. ``` -$ curl -H "Authorization: Bearer ." \ +$ curl -H "Authorization: Bearer nbt_4F9DAouzURLb.zjebxBPzICiPbWz0Wtx0fTL7bCKXKGTYhNzkgC2S" \ -H "Accept: application/json; indent=4" \ https://netbox/api/dcim/sites/ { diff --git a/netbox/core/tests/test_api.py b/netbox/core/tests/test_api.py index 29530bfa6..a1dcf04d5 100644 --- a/netbox/core/tests/test_api.py +++ b/netbox/core/tests/test_api.py @@ -8,6 +8,7 @@ from rq.job import Job as RQ_Job, JobStatus from rq.registry import FailedJobRegistry, StartedJobRegistry from rest_framework import status +from users.constants import TOKEN_PREFIX from users.models import Token, User from utilities.testing import APITestCase, APIViewTestCases, TestCase from utilities.testing.utils import disable_logging @@ -136,7 +137,7 @@ class BackgroundTaskTestCase(TestCase): # Create the test user and assign permissions self.user = User.objects.create_user(username='testuser', is_active=True) self.token = Token.objects.create(user=self.user) - self.header = {'HTTP_AUTHORIZATION': f'Bearer {self.token.key}.{self.token.token}'} + self.header = {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{self.token.key}.{self.token.token}'} # Clear all queues prior to running each test get_queue('default').connection.flushall() diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index 27247169a..daa512ee0 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -8,6 +8,7 @@ from rest_framework.authentication import BaseAuthentication, get_authorization_ from rest_framework.permissions import BasePermission, DjangoObjectPermissions, SAFE_METHODS from netbox.config import get_config +from users.constants import TOKEN_PREFIX from users.models import Token from utilities.request import get_client_ip @@ -22,40 +23,30 @@ class TokenAuthentication(BaseAuthentication): model = Token def authenticate(self, request): - # Ignore; Authorization header is not present + # Authorization header is not present; ignore if not (auth := get_authorization_header(request).split()): return - - # Infer token version from Token/Bearer keyword in HTTP header - if auth[0].lower() == V1_KEYWORD.lower().encode(): - version = 1 - elif auth[0].lower() == V2_KEYWORD.lower().encode(): - version = 2 - else: - # Ignore; unrecognized header value + # Unrecognized header; ignore + if auth[0].lower() not in (V1_KEYWORD.lower().encode(), V2_KEYWORD.lower().encode()): return - - # Extract token from authorization header. This should be in one of the following two forms: - # * Authorization: Token (v1) - # * Authorization: Bearer . (v2) + # Check for extraneous token content if len(auth) != 2: - if version == 1: - raise exceptions.AuthenticationFailed( - 'Invalid authorization header: Must be in the form "Token "' - ) - else: - raise exceptions.AuthenticationFailed( - 'Invalid authorization header: Must be in the form "Bearer ."' - ) - + raise exceptions.AuthenticationFailed( + 'Invalid authorization header: Must be in the form "Bearer ." or "Token "' + ) # Extract the key (if v2) & token plaintext from the auth header try: auth_value = auth[1].decode() except UnicodeError: raise exceptions.AuthenticationFailed("Invalid authorization header: Token contains invalid characters") + + # Infer token version from presence or absence of prefix + version = 2 if auth_value.startswith(TOKEN_PREFIX) else 1 + if version == 1: key, plaintext = None, auth_value else: + auth_value = auth_value.removeprefix(TOKEN_PREFIX) try: key, plaintext = auth_value.split('.', 1) except ValueError: diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index e33e72f5d..528d7e3f5 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -8,6 +8,7 @@ from rest_framework.test import APIClient from core.models import ObjectType from dcim.models import Rack, Site +from users.constants import TOKEN_PREFIX from users.models import Group, ObjectPermission, Token, User from utilities.testing import TestCase from utilities.testing.api import APITestCase @@ -49,7 +50,7 @@ class TokenAuthenticationTestCase(APITestCase): token = Token.objects.create(version=2, user=self.user) # Valid token should return a 200 - header = f'Bearer {token.key}.{token.token}' + header = f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}' response = self.client.get(reverse('dcim-api:site-list'), HTTP_AUTHORIZATION=header) self.assertEqual(response.status_code, 200, response.data) @@ -60,7 +61,7 @@ class TokenAuthenticationTestCase(APITestCase): @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) def test_v2_token_invalid(self): # Invalid token should return a 403 - header = 'Bearer XXXXXXXXXX.XXXXXXXXXX' + header = f'Bearer {TOKEN_PREFIX}XXXXXX.XXXXXXXXXX' response = self.client.get(reverse('dcim-api:site-list'), HTTP_AUTHORIZATION=header) self.assertEqual(response.status_code, 403) self.assertEqual(response.data['detail'], "Invalid v2 token") @@ -77,7 +78,7 @@ class TokenAuthenticationTestCase(APITestCase): # Request with a non-expired token should succeed response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token1.token}') self.assertEqual(response.status_code, 200) - response = self.client.get(url, HTTP_AUTHORIZATION=f'Bearer {token2.key}.{token2.token}') + response = self.client.get(url, HTTP_AUTHORIZATION=f'Bearer {TOKEN_PREFIX}{token2.key}.{token2.token}') self.assertEqual(response.status_code, 200) # Request with an expired token should fail @@ -88,7 +89,7 @@ class TokenAuthenticationTestCase(APITestCase): token2.save() response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token1.key}') self.assertEqual(response.status_code, 403) - response = self.client.get(url, HTTP_AUTHORIZATION=f'Bearer {token2.key}') + response = self.client.get(url, HTTP_AUTHORIZATION=f'Bearer {TOKEN_PREFIX}{token2.key}') self.assertEqual(response.status_code, 403) @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) @@ -111,7 +112,7 @@ class TokenAuthenticationTestCase(APITestCase): token2 = Token.objects.create(version=2, user=self.user, write_enabled=False) token1_header = f'Token {token1.token}' - token2_header = f'Bearer {token2.key}.{token2.token}' + token2_header = f'Bearer {TOKEN_PREFIX}{token2.key}.{token2.token}' # GET request with a write-disabled token should succeed response = self.client.get(url, HTTP_AUTHORIZATION=token1_header) @@ -152,7 +153,7 @@ class TokenAuthenticationTestCase(APITestCase): self.assertEqual(response.status_code, 403) response = self.client.get( url, - HTTP_AUTHORIZATION=f'Bearer {token2.key}.{token2.token}', + HTTP_AUTHORIZATION=f'Bearer {TOKEN_PREFIX}{token2.key}.{token2.token}', REMOTE_ADDR='127.0.0.1' ) self.assertEqual(response.status_code, 403) @@ -166,7 +167,7 @@ class TokenAuthenticationTestCase(APITestCase): self.assertEqual(response.status_code, 200) response = self.client.get( url, - HTTP_AUTHORIZATION=f'Bearer {token2.key}.{token2.token}', + HTTP_AUTHORIZATION=f'Bearer {TOKEN_PREFIX}{token2.key}.{token2.token}', REMOTE_ADDR='192.0.2.1' ) self.assertEqual(response.status_code, 200) @@ -519,7 +520,7 @@ class ObjectPermissionAPIViewTestCase(TestCase): """ self.user = User.objects.create(username='testuser') self.token = Token.objects.create(user=self.user) - self.header = {'HTTP_AUTHORIZATION': f'Bearer {self.token.key}.{self.token.token}'} + self.header = {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{self.token.key}.{self.token.token}'} @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_get_object(self): diff --git a/netbox/users/constants.py b/netbox/users/constants.py index 647249179..6a997073c 100644 --- a/netbox/users/constants.py +++ b/netbox/users/constants.py @@ -11,6 +11,7 @@ OBJECTPERMISSION_OBJECT_TYPES = Q( CONSTRAINT_TOKEN_USER = '$user' # API tokens -TOKEN_KEY_LENGTH = 16 +TOKEN_PREFIX = 'nbt_' # Used for v2 tokens only +TOKEN_KEY_LENGTH = 12 TOKEN_DEFAULT_LENGTH = 40 TOKEN_CHARSET = string.ascii_letters + string.digits diff --git a/netbox/users/migrations/0014_users_token_v2.py b/netbox/users/migrations/0014_users_token_v2.py index 1c0bb5c1d..df45cf85d 100644 --- a/netbox/users/migrations/0014_users_token_v2.py +++ b/netbox/users/migrations/0014_users_token_v2.py @@ -56,10 +56,10 @@ class Migration(migrations.Migration): name='key', field=models.CharField( blank=True, - max_length=16, + max_length=12, null=True, unique=True, - validators=[django.core.validators.MinLengthValidator(16)] + validators=[django.core.validators.MinLengthValidator(12)] ), ), migrations.AddField( diff --git a/netbox/users/models/tokens.py b/netbox/users/models/tokens.py index e452d2ab7..8d9da0ef6 100644 --- a/netbox/users/models/tokens.py +++ b/netbox/users/models/tokens.py @@ -15,7 +15,7 @@ from netaddr import IPNetwork from ipam.fields import IPNetworkField from users.choices import TokenVersionChoices -from users.constants import TOKEN_CHARSET, TOKEN_DEFAULT_LENGTH, TOKEN_KEY_LENGTH +from users.constants import TOKEN_CHARSET, TOKEN_DEFAULT_LENGTH, TOKEN_KEY_LENGTH, TOKEN_PREFIX from users.utils import get_current_pepper from utilities.querysets import RestrictedQuerySet @@ -235,6 +235,7 @@ class Token(models.Model): if self.v1: return token == self.token if self.v2: + token = token.removeprefix(TOKEN_PREFIX) try: pepper = settings.API_TOKEN_PEPPERS[self.pepper_id] except KeyError: diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index 973b05cb3..56cabef5d 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -17,6 +17,7 @@ from core.choices import ObjectChangeActionChoices from core.models import ObjectChange, ObjectType from ipam.graphql.types import IPAddressFamilyType from netbox.models.features import ChangeLoggingMixin +from users.constants import TOKEN_PREFIX from users.models import ObjectPermission, Token, User from utilities.api import get_graphql_type_for_model from .base import ModelTestCase @@ -50,7 +51,7 @@ class APITestCase(ModelTestCase): self.user = User.objects.create_user(username='testuser') self.add_permissions(*self.user_permissions) self.token = Token.objects.create(user=self.user) - self.header = {'HTTP_AUTHORIZATION': f'Bearer {self.token.key}.{self.token.token}'} + self.header = {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{self.token.key}.{self.token.token}'} def _get_view_namespace(self): return f'{self.view_namespace or self.model._meta.app_label}-api' From fcdb7ff6c847829e6f71e9e04336aa0e3c7e93c0 Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Wed, 8 Oct 2025 14:33:23 -0400 Subject: [PATCH 028/125] Remove HTMX navigation --- netbox/netbox/context_processors.py | 1 - netbox/netbox/preferences.py | 10 ------- netbox/templates/base/layout.html | 2 +- netbox/templates/inc/user_menu.html | 2 +- netbox/users/forms/model_forms.py | 3 +- .../utilities/templates/navigation/menu.html | 2 +- .../utilities/templatetags/builtins/tags.py | 7 ++--- netbox/utilities/templatetags/buttons.py | 2 -- netbox/utilities/templatetags/navigation.py | 28 ------------------- 9 files changed, 6 insertions(+), 51 deletions(-) diff --git a/netbox/netbox/context_processors.py b/netbox/netbox/context_processors.py index 3065855e6..199db9ee2 100644 --- a/netbox/netbox/context_processors.py +++ b/netbox/netbox/context_processors.py @@ -28,7 +28,6 @@ def preferences(request): user_preferences = request.user.config if request.user.is_authenticated else {} return { 'preferences': user_preferences, - 'htmx_navigation': user_preferences.get('ui.htmx_navigation', False) == 'true' } diff --git a/netbox/netbox/preferences.py b/netbox/netbox/preferences.py index d8fb130f4..49978b8c6 100644 --- a/netbox/netbox/preferences.py +++ b/netbox/netbox/preferences.py @@ -26,16 +26,6 @@ def get_csv_delimiters(): PREFERENCES = { # User interface - 'ui.htmx_navigation': UserPreference( - label=_('HTMX Navigation'), - choices=( - ('', _('Disabled')), - ('true', _('Enabled')), - ), - description=_('Enable dynamic UI navigation'), - default=False, - warning=_('Experimental feature') - ), 'locale.language': UserPreference( label=_('Language'), choices=( diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index c31896256..c305d1a70 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -95,7 +95,7 @@ Blocks: {# Page content #}
-
+
{# Page header #} {% block header %} diff --git a/netbox/templates/inc/user_menu.html b/netbox/templates/inc/user_menu.html index 50173ea56..ecafc1f39 100644 --- a/netbox/templates/inc/user_menu.html +++ b/netbox/templates/inc/user_menu.html @@ -33,7 +33,7 @@
-
{% if perms.core.view_objectchange %} diff --git a/netbox/tenancy/api/serializers_/contacts.py b/netbox/tenancy/api/serializers_/contacts.py index fd4d1ac8e..19c496139 100644 --- a/netbox/tenancy/api/serializers_/contacts.py +++ b/netbox/tenancy/api/serializers_/contacts.py @@ -4,7 +4,9 @@ from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField -from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer +from netbox.api.serializers import ( + NestedGroupModelSerializer, NetBoxModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, +) from tenancy.choices import ContactPriorityChoices from tenancy.models import ContactAssignment, Contact, ContactGroup, ContactRole from utilities.api import get_serializer_for_model @@ -26,23 +28,23 @@ class ContactGroupSerializer(NestedGroupModelSerializer): model = ContactGroup fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'contact_count', 'comments', '_depth', + 'created', 'last_updated', 'contact_count', 'owner', 'comments', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'contact_count', '_depth') -class ContactRoleSerializer(NetBoxModelSerializer): +class ContactRoleSerializer(OrganizationalModelSerializer): class Meta: model = ContactRole fields = [ - 'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', + 'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'owner', 'tags', 'custom_fields', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description') -class ContactSerializer(NetBoxModelSerializer): +class ContactSerializer(PrimaryModelSerializer): groups = SerializedPKRelatedField( queryset=ContactGroup.objects.all(), serializer=ContactGroupSerializer, @@ -55,7 +57,7 @@ class ContactSerializer(NetBoxModelSerializer): model = Contact fields = [ 'id', 'url', 'display_url', 'display', 'groups', 'name', 'title', 'phone', 'email', 'address', 'link', - 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/tenancy/api/serializers_/tenants.py b/netbox/tenancy/api/serializers_/tenants.py index 189397c70..277bdc987 100644 --- a/netbox/tenancy/api/serializers_/tenants.py +++ b/netbox/tenancy/api/serializers_/tenants.py @@ -1,7 +1,7 @@ from rest_framework import serializers from netbox.api.fields import RelatedObjectCountField -from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer +from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer from tenancy.models import Tenant, TenantGroup from .nested import NestedTenantGroupSerializer @@ -19,12 +19,12 @@ class TenantGroupSerializer(NestedGroupModelSerializer): model = TenantGroup fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'tenant_count', 'comments', '_depth', + 'created', 'last_updated', 'tenant_count', 'owner', 'comments', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'tenant_count', '_depth') -class TenantSerializer(NetBoxModelSerializer): +class TenantSerializer(PrimaryModelSerializer): group = TenantGroupSerializer(nested=True, required=False, allow_null=True, default=None) # Related object counts @@ -42,7 +42,7 @@ class TenantSerializer(NetBoxModelSerializer): class Meta: model = Tenant fields = [ - 'id', 'url', 'display_url', 'display', 'name', 'slug', 'group', 'description', 'comments', 'tags', + 'id', 'url', 'display_url', 'display', 'name', 'slug', 'group', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'ipaddress_count', 'prefix_count', 'rack_count', 'site_count', 'virtualmachine_count', 'vlan_count', 'vrf_count', 'cluster_count', diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index ca0142db6..b650ea882 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -2,7 +2,9 @@ import django_filters from django.db.models import Q from django.utils.translation import gettext as _ -from netbox.filtersets import NestedGroupModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet +from netbox.filtersets import ( + NestedGroupModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet, +) from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter from .models import * @@ -64,7 +66,7 @@ class ContactRoleFilterSet(OrganizationalModelFilterSet): fields = ('id', 'name', 'slug', 'description') -class ContactFilterSet(NetBoxModelFilterSet): +class ContactFilterSet(PrimaryModelFilterSet): group_id = TreeNodeMultipleChoiceFilter( queryset=ContactGroup.objects.all(), field_name='groups', @@ -198,7 +200,7 @@ class TenantGroupFilterSet(NestedGroupModelFilterSet): fields = ('id', 'name', 'slug', 'description') -class TenantFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): +class TenantFilterSet(PrimaryModelFilterSet, ContactModelFilterSet): group_id = TreeNodeMultipleChoiceFilter( queryset=TenantGroup.objects.all(), field_name='group', diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index 22aba810f..2ad442d22 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -1,11 +1,13 @@ from django import forms from django.utils.translation import gettext_lazy as _ -from netbox.forms import NetBoxModelBulkEditForm +from netbox.forms import ( + NestedGroupModelBulkEditForm, NetBoxModelBulkEditForm, OrganizationalModelBulkEditForm, PrimaryModelBulkEditForm, +) from tenancy.choices import ContactPriorityChoices from tenancy.models import * from utilities.forms import add_blank_choice -from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField +from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.rendering import FieldSet __all__ = ( @@ -22,34 +24,23 @@ __all__ = ( # Tenants # -class TenantGroupBulkEditForm(NetBoxModelBulkEditForm): +class TenantGroupBulkEditForm(NestedGroupModelBulkEditForm): parent = DynamicModelChoiceField( label=_('Parent'), queryset=TenantGroup.objects.all(), required=False ) - description = forms.CharField( - label=_('Description'), - max_length=200, - required=False - ) - comments = CommentField() model = TenantGroup nullable_fields = ('parent', 'description', 'comments') -class TenantBulkEditForm(NetBoxModelBulkEditForm): +class TenantBulkEditForm(PrimaryModelBulkEditForm): group = DynamicModelChoiceField( label=_('Group'), queryset=TenantGroup.objects.all(), required=False ) - description = forms.CharField( - label=_('Description'), - max_length=200, - required=False - ) model = Tenant fieldsets = ( @@ -62,18 +53,12 @@ class TenantBulkEditForm(NetBoxModelBulkEditForm): # Contacts # -class ContactGroupBulkEditForm(NetBoxModelBulkEditForm): +class ContactGroupBulkEditForm(NestedGroupModelBulkEditForm): parent = DynamicModelChoiceField( label=_('Parent'), queryset=ContactGroup.objects.all(), required=False ) - description = forms.CharField( - label=_('Description'), - max_length=200, - required=False - ) - comments = CommentField() model = ContactGroup fieldsets = ( @@ -82,13 +67,7 @@ class ContactGroupBulkEditForm(NetBoxModelBulkEditForm): nullable_fields = ('parent', 'description', 'comments') -class ContactRoleBulkEditForm(NetBoxModelBulkEditForm): - description = forms.CharField( - label=_('Description'), - max_length=200, - required=False - ) - +class ContactRoleBulkEditForm(OrganizationalModelBulkEditForm): model = ContactRole fieldsets = ( FieldSet('description'), @@ -96,7 +75,7 @@ class ContactRoleBulkEditForm(NetBoxModelBulkEditForm): nullable_fields = ('description',) -class ContactBulkEditForm(NetBoxModelBulkEditForm): +class ContactBulkEditForm(PrimaryModelBulkEditForm): add_groups = DynamicModelMultipleChoiceField( label=_('Add groups'), queryset=ContactGroup.objects.all(), @@ -131,12 +110,6 @@ class ContactBulkEditForm(NetBoxModelBulkEditForm): assume_scheme='https', required=False ) - description = forms.CharField( - label=_('Description'), - max_length=200, - required=False - ) - comments = CommentField() model = Contact fieldsets = ( diff --git a/netbox/tenancy/forms/bulk_import.py b/netbox/tenancy/forms/bulk_import.py index 5861c976b..5f9df428e 100644 --- a/netbox/tenancy/forms/bulk_import.py +++ b/netbox/tenancy/forms/bulk_import.py @@ -2,7 +2,10 @@ from django import forms from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ -from netbox.forms import NetBoxModelImportForm +from netbox.forms import ( + NestedGroupModelImportForm, NetBoxModelImportForm, OrganizationalModelImportForm, + PrimaryModelImportForm, +) from tenancy.models import * from utilities.forms.fields import CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField @@ -20,7 +23,7 @@ __all__ = ( # Tenants # -class TenantGroupImportForm(NetBoxModelImportForm): +class TenantGroupImportForm(NestedGroupModelImportForm): parent = CSVModelChoiceField( label=_('Parent'), queryset=TenantGroup.objects.all(), @@ -28,14 +31,13 @@ class TenantGroupImportForm(NetBoxModelImportForm): to_field_name='name', help_text=_('Parent group'), ) - slug = SlugField() class Meta: model = TenantGroup - fields = ('name', 'slug', 'parent', 'description', 'tags', 'comments') + fields = ('name', 'slug', 'parent', 'description', 'owner', 'comments', 'tags') -class TenantImportForm(NetBoxModelImportForm): +class TenantImportForm(PrimaryModelImportForm): slug = SlugField() group = CSVModelChoiceField( label=_('Group'), @@ -47,14 +49,14 @@ class TenantImportForm(NetBoxModelImportForm): class Meta: model = Tenant - fields = ('name', 'slug', 'group', 'description', 'comments', 'tags') + fields = ('name', 'slug', 'group', 'description', 'owner', 'comments', 'tags') # # Contacts # -class ContactGroupImportForm(NetBoxModelImportForm): +class ContactGroupImportForm(NestedGroupModelImportForm): parent = CSVModelChoiceField( label=_('Parent'), queryset=ContactGroup.objects.all(), @@ -62,22 +64,20 @@ class ContactGroupImportForm(NetBoxModelImportForm): to_field_name='name', help_text=_('Parent group'), ) - slug = SlugField() class Meta: model = ContactGroup - fields = ('name', 'slug', 'parent', 'description', 'tags', 'comments') + fields = ('name', 'slug', 'parent', 'description', 'owner', 'comments', 'tags') -class ContactRoleImportForm(NetBoxModelImportForm): - slug = SlugField() +class ContactRoleImportForm(OrganizationalModelImportForm): class Meta: model = ContactRole - fields = ('name', 'slug', 'description') + fields = ('name', 'slug', 'description', 'owner', 'tags') -class ContactImportForm(NetBoxModelImportForm): +class ContactImportForm(PrimaryModelImportForm): groups = CSVModelMultipleChoiceField( queryset=ContactGroup.objects.all(), required=False, @@ -92,7 +92,9 @@ class ContactImportForm(NetBoxModelImportForm): class Meta: model = Contact - fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'groups', 'description', 'comments', 'tags') + fields = ( + 'name', 'title', 'phone', 'email', 'address', 'link', 'groups', 'description', 'owner', 'comments', 'tags', + ) class ContactAssignmentImportForm(NetBoxModelImportForm): diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index 6541d9693..239a765c6 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -2,7 +2,10 @@ from django import forms from django.utils.translation import gettext_lazy as _ from core.models import ObjectType -from netbox.forms import NetBoxModelFilterSetForm +from netbox.forms import ( + NestedGroupModelFilterSetForm, NetBoxModelFilterSetForm, OrganizationalModelFilterSetForm, + PrimaryModelFilterSetForm, +) from tenancy.choices import * from tenancy.models import * from tenancy.forms import ContactModelFilterForm @@ -25,8 +28,12 @@ __all__ = ( # Tenants # -class TenantGroupFilterForm(NetBoxModelFilterSetForm): +class TenantGroupFilterForm(NestedGroupModelFilterSetForm): model = TenantGroup + fieldsets = ( + FieldSet('q', 'filter_id', 'tag', 'owner_id'), + FieldSet('parent_id', name=_('Tenant Group')), + ) parent_id = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), required=False, @@ -35,10 +42,11 @@ class TenantGroupFilterForm(NetBoxModelFilterSetForm): tag = TagFilterField(model) -class TenantFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): +class TenantFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm): model = Tenant fieldsets = ( - FieldSet('q', 'filter_id', 'tag', 'group_id'), + FieldSet('q', 'filter_id', 'tag', 'owner_id'), + FieldSet('group_id', name=_('Tenant')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')) ) group_id = DynamicModelMultipleChoiceField( @@ -54,8 +62,12 @@ class TenantFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): # Contacts # -class ContactGroupFilterForm(NetBoxModelFilterSetForm): +class ContactGroupFilterForm(NestedGroupModelFilterSetForm): model = ContactGroup + fieldsets = ( + FieldSet('q', 'filter_id', 'tag', 'owner_id'), + FieldSet('parent_id', name=_('Contact Group')), + ) parent_id = DynamicModelMultipleChoiceField( queryset=ContactGroup.objects.all(), required=False, @@ -64,13 +76,20 @@ class ContactGroupFilterForm(NetBoxModelFilterSetForm): tag = TagFilterField(model) -class ContactRoleFilterForm(NetBoxModelFilterSetForm): +class ContactRoleFilterForm(OrganizationalModelFilterSetForm): model = ContactRole + fieldsets = ( + FieldSet('q', 'filter_id', 'tag', 'owner_id'), + ) tag = TagFilterField(model) -class ContactFilterForm(NetBoxModelFilterSetForm): +class ContactFilterForm(PrimaryModelFilterSetForm): model = Contact + fieldsets = ( + FieldSet('q', 'filter_id', 'tag', 'owner_id'), + FieldSet('group_id', name=_('Contact')), + ) group_id = DynamicModelMultipleChoiceField( queryset=ContactGroup.objects.all(), required=False, diff --git a/netbox/tenancy/forms/model_forms.py b/netbox/tenancy/forms/model_forms.py index 5b1bd7339..719ea8a93 100644 --- a/netbox/tenancy/forms/model_forms.py +++ b/netbox/tenancy/forms/model_forms.py @@ -1,9 +1,9 @@ from django import forms from django.utils.translation import gettext_lazy as _ -from netbox.forms import NetBoxModelForm +from netbox.forms import NestedGroupModelForm, NetBoxModelForm, OrganizationalModelForm, PrimaryModelForm from tenancy.models import * -from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField +from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField from utilities.forms.rendering import FieldSet, ObjectAttribute __all__ = ( @@ -20,14 +20,12 @@ __all__ = ( # Tenants # -class TenantGroupForm(NetBoxModelForm): +class TenantGroupForm(NestedGroupModelForm): parent = DynamicModelChoiceField( label=_('Parent'), queryset=TenantGroup.objects.all(), required=False ) - slug = SlugField() - comments = CommentField() fieldsets = ( FieldSet('parent', 'name', 'slug', 'description', 'tags', name=_('Tenant Group')), @@ -36,18 +34,17 @@ class TenantGroupForm(NetBoxModelForm): class Meta: model = TenantGroup fields = [ - 'parent', 'name', 'slug', 'description', 'tags', 'comments' + 'parent', 'name', 'slug', 'description', 'owner', 'comments', 'tags', ] -class TenantForm(NetBoxModelForm): +class TenantForm(PrimaryModelForm): slug = SlugField() group = DynamicModelChoiceField( label=_('Group'), queryset=TenantGroup.objects.all(), required=False ) - comments = CommentField() fieldsets = ( FieldSet('name', 'slug', 'group', 'description', 'tags', name=_('Tenant')), @@ -56,7 +53,7 @@ class TenantForm(NetBoxModelForm): class Meta: model = Tenant fields = ( - 'name', 'slug', 'group', 'description', 'comments', 'tags', + 'name', 'slug', 'group', 'description', 'owner', 'comments', 'tags', ) @@ -64,14 +61,12 @@ class TenantForm(NetBoxModelForm): # Contacts # -class ContactGroupForm(NetBoxModelForm): +class ContactGroupForm(NestedGroupModelForm): parent = DynamicModelChoiceField( label=_('Parent'), queryset=ContactGroup.objects.all(), required=False ) - slug = SlugField() - comments = CommentField() fieldsets = ( FieldSet('parent', 'name', 'slug', 'description', 'tags', name=_('Contact Group')), @@ -79,22 +74,20 @@ class ContactGroupForm(NetBoxModelForm): class Meta: model = ContactGroup - fields = ('parent', 'name', 'slug', 'description', 'tags', 'comments') + fields = ('parent', 'name', 'slug', 'description', 'owner', 'comments', 'tags') -class ContactRoleForm(NetBoxModelForm): - slug = SlugField() - +class ContactRoleForm(OrganizationalModelForm): fieldsets = ( FieldSet('name', 'slug', 'description', 'tags', name=_('Contact Role')), ) class Meta: model = ContactRole - fields = ('name', 'slug', 'description', 'tags') + fields = ('name', 'slug', 'description', 'owner', 'tags') -class ContactForm(NetBoxModelForm): +class ContactForm(PrimaryModelForm): groups = DynamicModelMultipleChoiceField( label=_('Groups'), queryset=ContactGroup.objects.all(), @@ -105,7 +98,6 @@ class ContactForm(NetBoxModelForm): assume_scheme='https', required=False, ) - comments = CommentField() fieldsets = ( FieldSet( @@ -117,7 +109,7 @@ class ContactForm(NetBoxModelForm): class Meta: model = Contact fields = ( - 'groups', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', 'tags', + 'groups', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'owner', 'comments', 'tags', ) widgets = { 'address': forms.Textarea(attrs={'rows': 3}), diff --git a/netbox/tenancy/graphql/types.py b/netbox/tenancy/graphql/types.py index a3713da93..89d2bb971 100644 --- a/netbox/tenancy/graphql/types.py +++ b/netbox/tenancy/graphql/types.py @@ -4,7 +4,7 @@ import strawberry import strawberry_django from extras.graphql.mixins import CustomFieldsMixin, TagsMixin, ContactsMixin -from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType +from netbox.graphql.types import BaseObjectType, NestedGroupObjectType, OrganizationalObjectType, PrimaryObjectType from tenancy import models from .filters import * from .mixins import ContactAssignmentsMixin @@ -57,7 +57,7 @@ __all__ = ( filters=TenantFilter, pagination=True ) -class TenantType(ContactsMixin, NetBoxObjectType): +class TenantType(ContactsMixin, PrimaryObjectType): group: Annotated['TenantGroupType', strawberry.lazy('tenancy.graphql.types')] | None asns: List[Annotated['ASNType', strawberry.lazy('ipam.graphql.types')]] circuits: List[Annotated['CircuitType', strawberry.lazy('circuits.graphql.types')]] @@ -91,7 +91,7 @@ class TenantType(ContactsMixin, NetBoxObjectType): filters=TenantGroupFilter, pagination=True ) -class TenantGroupType(OrganizationalObjectType): +class TenantGroupType(NestedGroupObjectType): parent: Annotated['TenantGroupType', strawberry.lazy('tenancy.graphql.types')] | None tenants: List[TenantType] @@ -108,7 +108,7 @@ class TenantGroupType(OrganizationalObjectType): filters=ContactFilter, pagination=True ) -class ContactType(ContactAssignmentsMixin, NetBoxObjectType): +class ContactType(ContactAssignmentsMixin, PrimaryObjectType): groups: List[Annotated['ContactGroupType', strawberry.lazy('tenancy.graphql.types')]] @@ -128,7 +128,7 @@ class ContactRoleType(ContactAssignmentsMixin, OrganizationalObjectType): filters=ContactGroupFilter, pagination=True ) -class ContactGroupType(OrganizationalObjectType): +class ContactGroupType(NestedGroupObjectType): parent: Annotated['ContactGroupType', strawberry.lazy('tenancy.graphql.types')] | None contacts: List[ContactType] diff --git a/netbox/tenancy/migrations/0021_owner.py b/netbox/tenancy/migrations/0021_owner.py new file mode 100644 index 000000000..4c1a52abb --- /dev/null +++ b/netbox/tenancy/migrations/0021_owner.py @@ -0,0 +1,47 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('tenancy', '0020_remove_contactgroupmembership'), + ('users', '0015_owner'), + ] + + operations = [ + migrations.AddField( + model_name='contact', + name='owner', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + ), + ), + migrations.AddField( + model_name='contactgroup', + name='owner', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + ), + ), + migrations.AddField( + model_name='contactrole', + name='owner', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + ), + ), + migrations.AddField( + model_name='tenant', + name='owner', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + ), + ), + migrations.AddField( + model_name='tenantgroup', + name='owner', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + ), + ), + ] diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py index fb9979140..8dfb4fb38 100644 --- a/netbox/tenancy/tables/contacts.py +++ b/netbox/tenancy/tables/contacts.py @@ -1,8 +1,8 @@ -from django.utils.translation import gettext_lazy as _ import django_tables2 as tables +from django.utils.translation import gettext_lazy as _ from django_tables2.utils import Accessor -from netbox.tables import NetBoxTable, columns +from netbox.tables import NestedGroupModelTable, NetBoxTable, OrganizationalModelTable, PrimaryModelTable, columns from tenancy.models import * from utilities.tables import linkify_phone @@ -14,15 +14,7 @@ __all__ = ( ) -class ContactGroupTable(NetBoxTable): - name = columns.MPTTColumn( - verbose_name=_('Name'), - linkify=True - ) - parent = tables.Column( - verbose_name=_('Parent'), - linkify=True, - ) +class ContactGroupTable(NestedGroupModelTable): contact_count = columns.LinkedCountColumn( viewname='tenancy:contact_list', url_params={'group_id': 'pk'}, @@ -31,11 +23,8 @@ class ContactGroupTable(NetBoxTable): tags = columns.TagColumn( url_name='tenancy:contactgroup_list' ) - comments = columns.MarkdownColumn( - verbose_name=_('Comments'), - ) - class Meta(NetBoxTable.Meta): + class Meta(NestedGroupModelTable.Meta): model = ContactGroup fields = ( 'pk', 'name', 'parent', 'contact_count', 'description', 'comments', 'slug', 'tags', 'created', @@ -44,7 +33,7 @@ class ContactGroupTable(NetBoxTable): default_columns = ('pk', 'name', 'contact_count', 'description') -class ContactRoleTable(NetBoxTable): +class ContactRoleTable(OrganizationalModelTable): name = tables.Column( verbose_name=_('Name'), linkify=True @@ -53,13 +42,13 @@ class ContactRoleTable(NetBoxTable): url_name='tenancy:contactrole_list' ) - class Meta(NetBoxTable.Meta): + class Meta(OrganizationalModelTable.Meta): model = ContactRole fields = ('pk', 'name', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions') default_columns = ('pk', 'name', 'description') -class ContactTable(NetBoxTable): +class ContactTable(PrimaryModelTable): name = tables.Column( verbose_name=_('Name'), linkify=True @@ -72,9 +61,6 @@ class ContactTable(NetBoxTable): verbose_name=_('Phone'), linkify=linkify_phone, ) - comments = columns.MarkdownColumn( - verbose_name=_('Comments'), - ) assignment_count = columns.LinkedCountColumn( viewname='tenancy:contactassignment_list', url_params={'contact_id': 'pk'}, @@ -84,7 +70,7 @@ class ContactTable(NetBoxTable): url_name='tenancy:contact_list' ) - class Meta(NetBoxTable.Meta): + class Meta(PrimaryModelTable.Meta): model = Contact fields = ( 'pk', 'name', 'groups', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', diff --git a/netbox/tenancy/tables/tenants.py b/netbox/tenancy/tables/tenants.py index b7e7f40df..d36ae1386 100644 --- a/netbox/tenancy/tables/tenants.py +++ b/netbox/tenancy/tables/tenants.py @@ -1,25 +1,17 @@ -from django.utils.translation import gettext_lazy as _ import django_tables2 as tables +from django.utils.translation import gettext_lazy as _ + +from netbox.tables import NestedGroupModelTable, PrimaryModelTable, columns from tenancy.models import * from tenancy.tables import ContactsColumnMixin -from netbox.tables import NetBoxTable, columns - __all__ = ( 'TenantGroupTable', 'TenantTable', ) -class TenantGroupTable(NetBoxTable): - name = columns.MPTTColumn( - verbose_name=_('Name'), - linkify=True - ) - parent = tables.Column( - verbose_name=_('Parent'), - linkify=True, - ) +class TenantGroupTable(NestedGroupModelTable): tenant_count = columns.LinkedCountColumn( viewname='tenancy:tenant_list', url_params={'group_id': 'pk'}, @@ -28,11 +20,8 @@ class TenantGroupTable(NetBoxTable): tags = columns.TagColumn( url_name='tenancy:tenantgroup_list' ) - comments = columns.MarkdownColumn( - verbose_name=_('Comments'), - ) - class Meta(NetBoxTable.Meta): + class Meta(NestedGroupModelTable.Meta): model = TenantGroup fields = ( 'pk', 'id', 'name', 'parent', 'tenant_count', 'description', 'comments', 'slug', 'tags', 'created', @@ -41,7 +30,7 @@ class TenantGroupTable(NetBoxTable): default_columns = ('pk', 'name', 'tenant_count', 'description') -class TenantTable(ContactsColumnMixin, NetBoxTable): +class TenantTable(ContactsColumnMixin, PrimaryModelTable): name = tables.Column( verbose_name=_('Name'), linkify=True @@ -50,14 +39,11 @@ class TenantTable(ContactsColumnMixin, NetBoxTable): verbose_name=_('Group'), linkify=True ) - comments = columns.MarkdownColumn( - verbose_name=_('Comments'), - ) tags = columns.TagColumn( url_name='tenancy:tenant_list' ) - class Meta(NetBoxTable.Meta): + class Meta(PrimaryModelTable.Meta): model = Tenant fields = ( 'pk', 'id', 'name', 'slug', 'group', 'description', 'comments', 'contacts', 'tags', 'created', diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 700061b8c..9e64515c2 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -1,3 +1,4 @@ from .serializers_.users import * from .serializers_.permissions import * from .serializers_.tokens import * +from .serializers_.owners import * diff --git a/netbox/users/api/serializers_/mixins.py b/netbox/users/api/serializers_/mixins.py new file mode 100644 index 000000000..b5f94cafd --- /dev/null +++ b/netbox/users/api/serializers_/mixins.py @@ -0,0 +1,18 @@ +from rest_framework import serializers + +from users.api.serializers_.owners import OwnerSerializer + +__all__ = ( + 'OwnerMixin', +) + + +class OwnerMixin(serializers.Serializer): + """ + Adds an `owner` field for models which have a ForeignKey to users.Owner. + """ + owner = OwnerSerializer( + nested=True, + required=False, + allow_null=True, + ) diff --git a/netbox/users/api/serializers_/owners.py b/netbox/users/api/serializers_/owners.py new file mode 100644 index 000000000..2d704d591 --- /dev/null +++ b/netbox/users/api/serializers_/owners.py @@ -0,0 +1,45 @@ +from netbox.api.fields import RelatedObjectCountField, SerializedPKRelatedField +from netbox.api.serializers import ValidatedModelSerializer +from users.models import Group, Owner, OwnerGroup, User +from .users import GroupSerializer, UserSerializer + +__all__ = ( + 'OwnerGroupSerializer', + 'OwnerSerializer', +) + + +class OwnerGroupSerializer(ValidatedModelSerializer): + # Related object counts + member_count = RelatedObjectCountField('members') + + class Meta: + model = OwnerGroup + fields = ('id', 'url', 'display_url', 'display', 'name', 'description', 'member_count') + brief_fields = ('id', 'url', 'display', 'name', 'description') + + +class OwnerSerializer(ValidatedModelSerializer): + group = OwnerGroupSerializer( + nested=True, + allow_null=True, + ) + user_groups = SerializedPKRelatedField( + queryset=Group.objects.all(), + serializer=GroupSerializer, + nested=True, + required=False, + many=True + ) + users = SerializedPKRelatedField( + queryset=User.objects.all(), + serializer=UserSerializer, + nested=True, + required=False, + many=True + ) + + class Meta: + model = Owner + fields = ('id', 'url', 'display_url', 'display', 'name', 'group', 'description', 'user_groups', 'users') + brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/users/api/urls.py b/netbox/users/api/urls.py index 599d0bb61..8ee9edd5b 100644 --- a/netbox/users/api/urls.py +++ b/netbox/users/api/urls.py @@ -7,17 +7,12 @@ from . import views router = NetBoxRouter() router.APIRootView = views.UsersRootView -# Users and groups router.register('users', views.UserViewSet) router.register('groups', views.GroupViewSet) - -# Tokens router.register('tokens', views.TokenViewSet) - -# Permissions router.register('permissions', views.ObjectPermissionViewSet) - -# User preferences +router.register('owner-groups', views.OwnerGroupViewSet) +router.register('owners', views.OwnerViewSet) router.register('config', views.UserConfigViewSet, basename='userconfig') app_name = 'users-api' diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py index bba9a4ec3..de48dc17b 100644 --- a/netbox/users/api/views.py +++ b/netbox/users/api/views.py @@ -12,7 +12,7 @@ from rest_framework.viewsets import ViewSet from netbox.api.viewsets import NetBoxModelViewSet from users import filtersets -from users.models import Group, ObjectPermission, Token, User, UserConfig +from users.models import Group, ObjectPermission, Owner, OwnerGroup, Token, User, UserConfig from utilities.data import deepmerge from utilities.querysets import RestrictedQuerySet from . import serializers @@ -88,6 +88,22 @@ class ObjectPermissionViewSet(NetBoxModelViewSet): filterset_class = filtersets.ObjectPermissionFilterSet +# +# Owners +# + +class OwnerGroupViewSet(NetBoxModelViewSet): + queryset = OwnerGroup.objects.all() + serializer_class = serializers.OwnerGroupSerializer + filterset_class = filtersets.OwnerGroupFilterSet + + +class OwnerViewSet(NetBoxModelViewSet): + queryset = Owner.objects.all() + serializer_class = serializers.OwnerSerializer + filterset_class = filtersets.OwnerFilterSet + + # # User preferences # diff --git a/netbox/users/filterset_mixins.py b/netbox/users/filterset_mixins.py new file mode 100644 index 000000000..33eb6f743 --- /dev/null +++ b/netbox/users/filterset_mixins.py @@ -0,0 +1,24 @@ +import django_filters +from django.utils.translation import gettext as _ + +from users.models import Owner + +__all__ = ( + 'OwnerFilterMixin', +) + + +class OwnerFilterMixin(django_filters.FilterSet): + """ + Adds owner & owner_id filters for models which inherit from OwnerMixin. + """ + owner_id = django_filters.ModelMultipleChoiceFilter( + queryset=Owner.objects.all(), + label=_('Owner (ID)'), + ) + owner = django_filters.ModelMultipleChoiceFilter( + field_name='owner__name', + queryset=Owner.objects.all(), + to_field_name='name', + label=_('Owner (name)'), + ) diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index 36fbdcb0d..c53166b5d 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -6,12 +6,14 @@ from django.utils.translation import gettext as _ from core.models import ObjectType from extras.models import NotificationGroup from netbox.filtersets import BaseFilterSet -from users.models import Group, ObjectPermission, Token, User +from users.models import Group, ObjectPermission, Owner, OwnerGroup, Token, User from utilities.filters import ContentTypeFilter __all__ = ( 'GroupFilterSet', 'ObjectPermissionFilterSet', + 'OwnerFilterSet', + 'OwnerGroupFilterSet', 'TokenFilterSet', 'UserFilterSet', ) @@ -27,6 +29,17 @@ class GroupFilterSet(BaseFilterSet): queryset=User.objects.all(), label=_('User (ID)'), ) + owner_id = django_filters.ModelMultipleChoiceFilter( + field_name='owner', + queryset=Owner.objects.all(), + label=_('Owner (ID)'), + ) + owner = django_filters.ModelMultipleChoiceFilter( + field_name='owner__name', + queryset=Owner.objects.all(), + to_field_name='name', + label=_('Owner (name)'), + ) permission_id = django_filters.ModelMultipleChoiceFilter( field_name='object_permissions', queryset=ObjectPermission.objects.all(), @@ -67,6 +80,17 @@ class UserFilterSet(BaseFilterSet): to_field_name='name', label=_('Group (name)'), ) + owner_id = django_filters.ModelMultipleChoiceFilter( + field_name='owner', + queryset=Owner.objects.all(), + label=_('Owner (ID)'), + ) + owner = django_filters.ModelMultipleChoiceFilter( + field_name='owner__name', + queryset=Owner.objects.all(), + to_field_name='name', + label=_('Owner (name)'), + ) permission_id = django_filters.ModelMultipleChoiceFilter( field_name='object_permissions', queryset=ObjectPermission.objects.all(), @@ -221,3 +245,73 @@ class ObjectPermissionFilterSet(BaseFilterSet): return queryset.filter(actions__contains=[action]) else: return queryset.exclude(actions__contains=[action]) + + +class OwnerGroupFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label=_('Search'), + ) + + class Meta: + model = OwnerGroup + fields = ('id', 'name', 'description') + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) + ) + + +class OwnerFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label=_('Search'), + ) + group_id = django_filters.ModelMultipleChoiceFilter( + queryset=OwnerGroup.objects.all(), + label=_('Group (ID)'), + ) + group = django_filters.ModelMultipleChoiceFilter( + field_name='group__name', + queryset=OwnerGroup.objects.all(), + to_field_name='name', + label=_('Group (name)'), + ) + user_group_id = django_filters.ModelMultipleChoiceFilter( + field_name='user_groups', + queryset=Group.objects.all(), + label=_('User group (ID)'), + ) + user_group = django_filters.ModelMultipleChoiceFilter( + field_name='user_groups__name', + queryset=Group.objects.all(), + to_field_name='name', + label=_('User group (name)'), + ) + user_id = django_filters.ModelMultipleChoiceFilter( + field_name='users', + queryset=User.objects.all(), + label=_('User (ID)'), + ) + user = django_filters.ModelMultipleChoiceFilter( + field_name='users__username', + queryset=User.objects.all(), + to_field_name='username', + label=_('User (username)'), + ) + + class Meta: + model = Owner + fields = ('id', 'name', 'description') + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) + ) diff --git a/netbox/users/forms/bulk_edit.py b/netbox/users/forms/bulk_edit.py index bca417b3d..227711d9b 100644 --- a/netbox/users/forms/bulk_edit.py +++ b/netbox/users/forms/bulk_edit.py @@ -6,12 +6,15 @@ from ipam.formfields import IPNetworkFormField from ipam.validators import prefix_validator from users.models import * from utilities.forms import BulkEditForm +from utilities.forms.fields import DynamicModelChoiceField from utilities.forms.rendering import FieldSet from utilities.forms.widgets import BulkEditNullBooleanSelect, DateTimePicker __all__ = ( 'GroupBulkEditForm', 'ObjectPermissionBulkEditForm', + 'OwnerBulkEditForm', + 'OwnerGroupBulkEditForm', 'UserBulkEditForm', 'TokenBulkEditForm', ) @@ -124,3 +127,44 @@ class TokenBulkEditForm(BulkEditForm): nullable_fields = ( 'expires', 'description', 'allowed_ips', ) + + +class OwnerGroupBulkEditForm(BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=OwnerGroup.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + label=_('Description'), + max_length=200, + required=False + ) + + model = OwnerGroup + fieldsets = ( + FieldSet('description',), + ) + nullable_fields = ('description',) + + +class OwnerBulkEditForm(BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Owner.objects.all(), + widget=forms.MultipleHiddenInput + ) + group = DynamicModelChoiceField( + label=_('Group'), + queryset=OwnerGroup.objects.all(), + required=False + ) + description = forms.CharField( + label=_('Description'), + max_length=200, + required=False + ) + + model = Owner + fieldsets = ( + FieldSet('group', 'description'), + ) + nullable_fields = ('group', 'description',) diff --git a/netbox/users/forms/bulk_import.py b/netbox/users/forms/bulk_import.py index bdda61a44..776333c7b 100644 --- a/netbox/users/forms/bulk_import.py +++ b/netbox/users/forms/bulk_import.py @@ -3,10 +3,13 @@ from django.utils.translation import gettext as _ from users.models import * from users.choices import TokenVersionChoices from utilities.forms import CSVModelForm +from utilities.forms.fields import CSVModelChoiceField, CSVModelMultipleChoiceField __all__ = ( 'GroupImportForm', + 'OwnerGroupImportForm', + 'OwnerImportForm', 'UserImportForm', 'TokenImportForm', ) @@ -50,3 +53,36 @@ class TokenImportForm(CSVModelForm): class Meta: model = Token fields = ('user', 'version', 'token', 'write_enabled', 'expires', 'description',) + + +class OwnerGroupImportForm(CSVModelForm): + + class Meta: + model = OwnerGroup + fields = ( + 'name', 'description', + ) + + +class OwnerImportForm(CSVModelForm): + group = CSVModelChoiceField( + queryset=OwnerGroup.objects.all(), + required=False, + to_field_name='name', + ) + user_groups = CSVModelMultipleChoiceField( + queryset=Group.objects.all(), + required=False, + to_field_name='name', + ) + users = CSVModelMultipleChoiceField( + queryset=User.objects.all(), + required=False, + to_field_name='username', + ) + + class Meta: + model = Owner + fields = ( + 'group', 'name', 'description', 'user_groups', 'users', + ) diff --git a/netbox/users/forms/filtersets.py b/netbox/users/forms/filtersets.py index 32e52b5f9..df5bc4da1 100644 --- a/netbox/users/forms/filtersets.py +++ b/netbox/users/forms/filtersets.py @@ -4,7 +4,7 @@ from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelFilterSetForm from netbox.forms.mixins import SavedFiltersMixin from users.choices import TokenVersionChoices -from users.models import Group, ObjectPermission, Token, User +from users.models import Group, ObjectPermission, Owner, OwnerGroup, Token, User from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm from utilities.forms.fields import DynamicModelMultipleChoiceField from utilities.forms.rendering import FieldSet @@ -14,6 +14,8 @@ from utilities.forms.widgets import DateTimePicker __all__ = ( 'GroupFilterForm', 'ObjectPermissionFilterForm', + 'OwnerFilterForm', + 'OwnerGroupFilterForm', 'TokenFilterForm', 'UserFilterForm', ) @@ -140,3 +142,34 @@ class TokenFilterForm(SavedFiltersMixin, FilterForm): label=_('Last Used'), widget=DateTimePicker() ) + + +class OwnerGroupFilterForm(NetBoxModelFilterSetForm): + model = OwnerGroup + fieldsets = ( + FieldSet('q', 'filter_id',), + ) + + +class OwnerFilterForm(NetBoxModelFilterSetForm): + model = Owner + fieldsets = ( + FieldSet('q', 'filter_id',), + FieldSet('group_id', name=_('Group')), + FieldSet('user_group_id', 'user_id', name=_('Membership')), + ) + group_id = DynamicModelMultipleChoiceField( + queryset=OwnerGroup.objects.all(), + required=False, + label=_('Group') + ) + user_group_id = DynamicModelMultipleChoiceField( + queryset=Group.objects.all(), + required=False, + label=_('Groups') + ) + user_id = DynamicModelMultipleChoiceField( + queryset=User.objects.all(), + required=False, + label=_('Users') + ) diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index cae194331..f316e0cd4 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -15,7 +15,9 @@ from users.choices import TokenVersionChoices from users.constants import * from users.models import * from utilities.data import flatten_dict -from utilities.forms.fields import ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField +from utilities.forms.fields import ( + ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, +) from utilities.forms.rendering import FieldSet from utilities.forms.widgets import DateTimePicker, SplitMultiSelectWidget from utilities.permissions import qs_filter_from_constraints @@ -23,11 +25,12 @@ from utilities.permissions import qs_filter_from_constraints __all__ = ( 'GroupForm', 'ObjectPermissionForm', + 'OwnerForm', + 'OwnerGroupForm', 'TokenForm', 'UserConfigForm', 'UserForm', 'UserTokenForm', - 'TokenForm', ) @@ -431,3 +434,47 @@ class ObjectPermissionForm(forms.ModelForm): instance.groups.set(self.cleaned_data['groups']) return instance + + +class OwnerGroupForm(forms.ModelForm): + + fieldsets = ( + FieldSet('name', 'description', name=_('Owner Group')), + ) + + class Meta: + model = OwnerGroup + fields = [ + 'name', 'description', + ] + + +class OwnerForm(forms.ModelForm): + fieldsets = ( + FieldSet('name', 'group', 'description', name=_('Owner')), + FieldSet('user_groups', name=_('Groups')), + FieldSet('users', name=_('Users')), + ) + group = DynamicModelChoiceField( + label=_('Group'), + queryset=OwnerGroup.objects.all(), + required=False, + selector=True, + quick_add=True + ) + user_groups = DynamicModelMultipleChoiceField( + label=_('User groups'), + queryset=Group.objects.all(), + required=False + ) + users = DynamicModelMultipleChoiceField( + label=_('Users'), + queryset=User.objects.all(), + required=False + ) + + class Meta: + model = Owner + fields = [ + 'name', 'group', 'description', 'user_groups', 'users', + ] diff --git a/netbox/users/graphql/filters.py b/netbox/users/graphql/filters.py index 07f28bb88..52a768b85 100644 --- a/netbox/users/graphql/filters.py +++ b/netbox/users/graphql/filters.py @@ -10,6 +10,8 @@ from users import models __all__ = ( 'GroupFilter', + 'OwnerFilter', + 'OwnerGroupFilter', 'UserFilter', ) @@ -31,3 +33,22 @@ class UserFilter(BaseObjectTypeFilterMixin): date_joined: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() last_login: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() groups: Annotated['GroupFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.Owner, lookups=True) +class OwnerFilter(BaseObjectTypeFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + group: Annotated['OwnerGroupFilter', strawberry.lazy('users.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + user_groups: Annotated['GroupFilter', strawberry.lazy('users.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + users: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.OwnerGroup, lookups=True) +class OwnerGroupFilter(BaseObjectTypeFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/users/graphql/mixins.py b/netbox/users/graphql/mixins.py new file mode 100644 index 000000000..f185eba66 --- /dev/null +++ b/netbox/users/graphql/mixins.py @@ -0,0 +1,15 @@ +from typing import Annotated, TYPE_CHECKING + +import strawberry + +if TYPE_CHECKING: + from users.graphql.types import OwnerType + +__all__ = ( + 'OwnerMixin', +) + + +@strawberry.type +class OwnerMixin: + owner: Annotated['OwnerType', strawberry.lazy('users.graphql.types')] | None diff --git a/netbox/users/graphql/schema.py b/netbox/users/graphql/schema.py index b59266c57..5b6341d54 100644 --- a/netbox/users/graphql/schema.py +++ b/netbox/users/graphql/schema.py @@ -13,3 +13,9 @@ class UsersQuery: user: UserType = strawberry_django.field() user_list: List[UserType] = strawberry_django.field() + + owner_group: OwnerGroupType = strawberry_django.field() + owner_group_list: List[OwnerGroupType] = strawberry_django.field() + + owner: OwnerType = strawberry_django.field() + owner_list: List[OwnerType] = strawberry_django.field() diff --git a/netbox/users/graphql/types.py b/netbox/users/graphql/types.py index 5231194e5..e04fc8668 100644 --- a/netbox/users/graphql/types.py +++ b/netbox/users/graphql/types.py @@ -3,11 +3,13 @@ from typing import List import strawberry_django from netbox.graphql.types import BaseObjectType -from users.models import Group, User +from users.models import Group, Owner, OwnerGroup, User from .filters import * __all__ = ( 'GroupType', + 'OwnerGroupType', + 'OwnerType', 'UserType', ) @@ -32,3 +34,23 @@ class GroupType(BaseObjectType): ) class UserType(BaseObjectType): groups: List[GroupType] + + +@strawberry_django.type( + OwnerGroup, + fields=['id', 'name', 'description'], + filters=OwnerGroupFilter, + pagination=True +) +class OwnerGroupType(BaseObjectType): + pass + + +@strawberry_django.type( + Owner, + fields=['id', 'group', 'name', 'description', 'user_groups', 'users'], + filters=OwnerFilter, + pagination=True +) +class OwnerType(BaseObjectType): + group: OwnerGroupType | None diff --git a/netbox/users/migrations/0015_owner.py b/netbox/users/migrations/0015_owner.py new file mode 100644 index 000000000..1ccaf4880 --- /dev/null +++ b/netbox/users/migrations/0015_owner.py @@ -0,0 +1,66 @@ +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('users', '0014_users_token_v2'), + ] + + operations = [ + migrations.CreateModel( + name='OwnerGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('description', models.CharField(blank=True, max_length=200)), + ('name', models.CharField(max_length=100, unique=True)), + ], + options={ + 'verbose_name': 'owner group', + 'verbose_name_plural': 'owner groups', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Owner', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ( + 'group', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='members', + to='users.ownergroup', + ), + ), + ( + 'user_groups', + models.ManyToManyField( + blank=True, + related_name='owners', + related_query_name='owner', + to='users.group', + ), + ), + ( + 'users', + models.ManyToManyField( + blank=True, + related_name='owners', + related_query_name='owner', + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + 'verbose_name': 'owner', + 'verbose_name_plural': 'owners', + 'ordering': ('name',), + }, + ), + ] diff --git a/netbox/users/models/__init__.py b/netbox/users/models/__init__.py index 62a7b93fe..c6223e996 100644 --- a/netbox/users/models/__init__.py +++ b/netbox/users/models/__init__.py @@ -2,3 +2,4 @@ from .users import * from .preferences import * from .tokens import * from .permissions import * +from .owners import * diff --git a/netbox/users/models/owners.py b/netbox/users/models/owners.py new file mode 100644 index 000000000..2ddb1a030 --- /dev/null +++ b/netbox/users/models/owners.py @@ -0,0 +1,77 @@ +from django.db import models +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from netbox.models import AdminModel +from utilities.querysets import RestrictedQuerySet + +__all__ = ( + 'Owner', + 'OwnerGroup', +) + + +class OwnerGroup(AdminModel): + """ + An arbitrary grouping of Owners. + """ + name = models.CharField( + verbose_name=_('name'), + max_length=100, + unique=True, + ) + + class Meta: + ordering = ['name'] + verbose_name = _('owner group') + verbose_name_plural = _('owner groups') + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('users:ownergroup', args=[self.pk]) + + +class Owner(AdminModel): + name = models.CharField( + verbose_name=_('name'), + max_length=100, + unique=True, + ) + group = models.ForeignKey( + to='users.OwnerGroup', + on_delete=models.PROTECT, + related_name='members', + verbose_name=_('group'), + blank=True, + null=True, + ) + user_groups = models.ManyToManyField( + to='users.Group', + verbose_name=_('groups'), + blank=True, + related_name='owners', + related_query_name='owner', + ) + users = models.ManyToManyField( + to='users.User', + verbose_name=_('users'), + blank=True, + related_name='owners', + related_query_name='owner', + ) + + objects = RestrictedQuerySet.as_manager() + clone_fields = ('user_groups', 'users') + + class Meta: + ordering = ('name',) + verbose_name = _('owner') + verbose_name_plural = _('owners') + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('users:owner', args=[self.pk]) diff --git a/netbox/users/tables.py b/netbox/users/tables.py index 2b4bd745f..ff28545b1 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -2,11 +2,13 @@ import django_tables2 as tables from django.utils.translation import gettext as _ from netbox.tables import NetBoxTable, columns -from users.models import Group, ObjectPermission, Token, User +from users.models import Group, ObjectPermission, Owner, OwnerGroup, Token, User __all__ = ( 'GroupTable', 'ObjectPermissionTable', + 'OwnerGroupTable', + 'OwnerTable', 'TokenTable', 'UserTable', ) @@ -143,3 +145,54 @@ class ObjectPermissionTable(NetBoxTable): default_columns = ( 'pk', 'name', 'enabled', 'object_types', 'can_view', 'can_add', 'can_change', 'can_delete', 'description', ) + + +class OwnerGroupTable(NetBoxTable): + name = tables.Column( + verbose_name=_('Name'), + linkify=True + ) + owner_count = columns.LinkedCountColumn( + viewname='users:owner_list', + url_params={'group_id': 'pk'}, + verbose_name=_('Owners') + ) + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + ) + + class Meta(NetBoxTable.Meta): + model = OwnerGroup + fields = ( + 'pk', 'id', 'name', 'description', + ) + default_columns = ('pk', 'name', 'owner_count', 'description') + + +class OwnerTable(NetBoxTable): + name = tables.Column( + verbose_name=_('Name'), + linkify=True + ) + group = tables.Column( + verbose_name=_('Group'), + linkify=True, + ) + user_groups = columns.ManyToManyColumn( + verbose_name=_('Groups'), + linkify_item=('users:group', {'pk': tables.A('pk')}) + ) + users = columns.ManyToManyColumn( + verbose_name=_('Users'), + linkify_item=('users:user', {'pk': tables.A('pk')}) + ) + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + ) + + class Meta(NetBoxTable.Meta): + model = Owner + fields = ( + 'pk', 'id', 'name', 'group', 'description', 'user_groups', 'users', + ) + default_columns = ('pk', 'name', 'group', 'description', 'user_groups', 'users') diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py index 597ce77de..0e1ccebf8 100644 --- a/netbox/users/tests/test_api.py +++ b/netbox/users/tests/test_api.py @@ -3,7 +3,7 @@ from django.urls import reverse from core.models import ObjectType from users.constants import TOKEN_DEFAULT_LENGTH -from users.models import Group, ObjectPermission, Token, User +from users.models import Group, ObjectPermission, Owner, OwnerGroup, Token, User from utilities.data import deepmerge from utilities.testing import APIViewTestCases, APITestCase, create_test_user @@ -448,3 +448,112 @@ class UserConfigTest(APITestCase): self.assertDictEqual(response.data, new_data) userconfig.refresh_from_db() self.assertDictEqual(userconfig.data, new_data) + + +class OwnerGroupTest(APIViewTestCases.APIViewTestCase): + model = OwnerGroup + brief_fields = ['description', 'display', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + owner_groups = ( + OwnerGroup(name='Owner Group 1'), + OwnerGroup(name='Owner Group 2'), + OwnerGroup(name='Owner Group 3'), + ) + OwnerGroup.objects.bulk_create(owner_groups) + + cls.create_data = [ + { + 'name': 'Owner Group 4', + 'description': 'Fourth owner group', + }, + { + 'name': 'Owner Group 5', + 'description': 'Fifth owner group', + }, + { + 'name': 'Owner Group 6', + 'description': 'Sixth owner group', + }, + ] + + +class OwnerTest(APIViewTestCases.APIViewTestCase): + model = Owner + brief_fields = ['description', 'display', 'id', 'name', 'url'] + + @classmethod + def setUpTestData(cls): + owner_groups = ( + OwnerGroup(name='Owner Group 1'), + OwnerGroup(name='Owner Group 2'), + OwnerGroup(name='Owner Group 3'), + OwnerGroup(name='Owner Group 4'), + ) + OwnerGroup.objects.bulk_create(owner_groups) + + groups = ( + Group(name='Group 1'), + Group(name='Group 2'), + Group(name='Group 3'), + Group(name='Group 4'), + ) + Group.objects.bulk_create(groups) + + users = ( + User(username='User 1'), + User(username='User 2'), + User(username='User 3'), + User(username='User 4'), + ) + User.objects.bulk_create(users) + + owners = ( + Owner(name='Owner 1'), + Owner(name='Owner 2'), + Owner(name='Owner 3'), + ) + Owner.objects.bulk_create(owners) + + # Assign users and groups to owners + owners[0].user_groups.add(groups[0]) + owners[1].user_groups.add(groups[1]) + owners[2].user_groups.add(groups[2]) + owners[0].users.add(users[0]) + owners[1].users.add(users[1]) + owners[2].users.add(users[2]) + + cls.create_data = [ + { + 'name': 'Owner 4', + 'description': 'Fourth owner', + 'group': owner_groups[3].pk, + 'user_groups': [groups[3].pk], + 'users': [users[3].pk], + }, + { + 'name': 'Owner 5', + 'description': 'Fifth owner', + 'group': owner_groups[3].pk, + 'user_groups': [groups[3].pk], + 'users': [users[3].pk], + }, + { + 'name': 'Owner 6', + 'description': 'Sixth owner', + 'group': owner_groups[3].pk, + 'user_groups': [groups[3].pk], + 'users': [users[3].pk], + }, + ] + + cls.bulk_update_data = { + 'group': owner_groups[3].pk, + 'user_groups': [groups[3].pk], + 'users': [users[3].pk], + 'description': 'New description', + } diff --git a/netbox/users/tests/test_filtersets.py b/netbox/users/tests/test_filtersets.py index 1f7336cc3..745b00126 100644 --- a/netbox/users/tests/test_filtersets.py +++ b/netbox/users/tests/test_filtersets.py @@ -5,7 +5,7 @@ from django.utils.timezone import make_aware from core.models import ObjectType from users import filtersets -from users.models import Group, ObjectPermission, Token, User +from users.models import Group, ObjectPermission, Owner, OwnerGroup, Token, User from utilities.testing import BaseFilterSetTests @@ -348,3 +348,106 @@ class TokenTestCase(TestCase, BaseFilterSetTests): def test_description(self): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class OwnerGroupTestCase(TestCase, BaseFilterSetTests): + queryset = OwnerGroup.objects.all() + filterset = filtersets.OwnerGroupFilterSet + + @classmethod + def setUpTestData(cls): + + owner_groups = ( + OwnerGroup(name='Owner Group 1', description='Foo'), + OwnerGroup(name='Owner Group 2', description='Bar'), + OwnerGroup(name='Owner Group 3', description='Baz'), + ) + OwnerGroup.objects.bulk_create(owner_groups) + + def test_q(self): + params = {'q': 'foo'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_name(self): + params = {'name': ['Owner Group 1', 'Owner Group 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_description(self): + params = {'description': ['Foo', 'Bar']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class OwnerTestCase(TestCase, BaseFilterSetTests): + queryset = Owner.objects.all() + filterset = filtersets.OwnerFilterSet + + @classmethod + def setUpTestData(cls): + owner_groups = ( + OwnerGroup(name='Owner Group 1'), + OwnerGroup(name='Owner Group 2'), + OwnerGroup(name='Owner Group 3'), + ) + OwnerGroup.objects.bulk_create(owner_groups) + + groups = ( + Group(name='Group 1'), + Group(name='Group 2'), + Group(name='Group 3'), + ) + Group.objects.bulk_create(groups) + + users = ( + User(username='User 1'), + User(username='User 2'), + User(username='User 3'), + ) + User.objects.bulk_create(users) + + owners = ( + Owner(name='Owner 1', group=owner_groups[0], description='Foo'), + Owner(name='Owner 2', group=owner_groups[1], description='Bar'), + Owner(name='Owner 3', group=owner_groups[2], description='Baz'), + ) + Owner.objects.bulk_create(owners) + + # Assign users and groups to owners + owners[0].user_groups.add(groups[0]) + owners[1].user_groups.add(groups[1]) + owners[2].user_groups.add(groups[2]) + owners[0].users.add(users[0]) + owners[1].users.add(users[1]) + owners[2].users.add(users[2]) + + def test_q(self): + params = {'q': 'foo'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_name(self): + params = {'name': ['Owner 1', 'Owner 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_description(self): + params = {'description': ['Foo', 'Bar']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_group(self): + owner_groups = OwnerGroup.objects.order_by('id')[:2] + params = {'group_id': [owner_groups[0].pk, owner_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'group': [owner_groups[0].name, owner_groups[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_user_group(self): + group = Group.objects.order_by('id')[:2] + params = {'user_group_id': [group[0].pk, group[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'user_group': [group[0].name, group[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_user(self): + users = User.objects.order_by('id')[:2] + params = {'user_id': [users[0].pk, users[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'user': [users[0].username, users[1].username]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/users/tests/test_views.py b/netbox/users/tests/test_views.py index 24aec6941..1980299fd 100644 --- a/netbox/users/tests/test_views.py +++ b/netbox/users/tests/test_views.py @@ -255,3 +255,108 @@ class TokenTestCase( cls.bulk_edit_data = { 'description': 'New description', } + + +class OwnerGroupTestCase(ViewTestCases.AdminModelViewTestCase): + model = OwnerGroup + + @classmethod + def setUpTestData(cls): + owner_groups = ( + OwnerGroup(name='Owner Group 1'), + OwnerGroup(name='Owner Group 2'), + OwnerGroup(name='Owner Group 3'), + ) + OwnerGroup.objects.bulk_create(owner_groups) + + cls.form_data = { + 'name': 'Owner Group X', + 'description': 'A new owner group', + } + + cls.csv_data = ( + "name,description", + "Owner Group 4,Foo", + "Owner Group 5,Bar", + "Owner Group 6,Baz", + ) + + cls.csv_update_data = ( + "id,description", + f"{owner_groups[0].pk},Foo", + f"{owner_groups[1].pk},Bar", + f"{owner_groups[2].pk},Baz", + ) + + cls.bulk_edit_data = { + 'description': 'New description', + } + + +class OwnerTestCase(ViewTestCases.AdminModelViewTestCase): + model = Owner + + @classmethod + def setUpTestData(cls): + groups = ( + Group(name='Group 1'), + Group(name='Group 2'), + Group(name='Group 3'), + ) + Group.objects.bulk_create(groups) + + users = ( + User(username='User 1'), + User(username='User 2'), + User(username='User 3'), + ) + User.objects.bulk_create(users) + + owner_groups = ( + OwnerGroup(name='Owner Group 1'), + OwnerGroup(name='Owner Group 2'), + OwnerGroup(name='Owner Group 3'), + OwnerGroup(name='Owner Group 4'), + ) + OwnerGroup.objects.bulk_create(owner_groups) + + owners = ( + Owner(name='Owner 1'), + Owner(name='Owner 2'), + Owner(name='Owner 3'), + ) + Owner.objects.bulk_create(owners) + + # Assign users and groups to owners + owners[0].user_groups.add(groups[0]) + owners[1].user_groups.add(groups[1]) + owners[2].user_groups.add(groups[2]) + owners[0].users.add(users[0]) + owners[1].users.add(users[1]) + owners[2].users.add(users[2]) + + cls.form_data = { + 'name': 'Owner X', + 'group': owner_groups[3].pk, + 'user_groups': [groups[0].pk, groups[1].pk], + 'users': [users[0].pk, users[1].pk], + 'description': 'A new owner', + } + + cls.csv_data = ( + "name,group,description", + "Owner 4,Owner Group 4,Foo", + "Owner 5,Owner Group 4,Bar", + "Owner 6,Owner Group 4,Baz", + ) + + cls.csv_update_data = ( + "id,description", + f"{owners[0].pk},Foo", + f"{owners[1].pk},Bar", + f"{owners[2].pk},Baz", + ) + + cls.bulk_edit_data = { + 'description': 'New description', + } diff --git a/netbox/users/urls.py b/netbox/users/urls.py index 83f120702..d820295b0 100644 --- a/netbox/users/urls.py +++ b/netbox/users/urls.py @@ -18,4 +18,10 @@ urlpatterns = [ path('permissions/', include(get_model_urls('users', 'objectpermission', detail=False))), path('permissions//', include(get_model_urls('users', 'objectpermission'))), + path('owner-groups/', include(get_model_urls('users', 'ownergroup', detail=False))), + path('owner-groups//', include(get_model_urls('users', 'ownergroup'))), + + path('owners/', include(get_model_urls('users', 'owner', detail=False))), + path('owners//', include(get_model_urls('users', 'owner'))), + ] diff --git a/netbox/users/views.py b/netbox/users/views.py index 9071c6c8b..ffb1ab8c5 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -4,9 +4,10 @@ from core.models import ObjectChange from core.tables import ObjectChangeTable from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename from netbox.views import generic -from utilities.views import register_model_view +from utilities.query import count_related +from utilities.views import GetRelatedModelsMixin, register_model_view from . import filtersets, forms, tables -from .models import Group, User, ObjectPermission, Token +from .models import Group, User, ObjectPermission, Owner, OwnerGroup, Token # @@ -231,3 +232,131 @@ class ObjectPermissionBulkDeleteView(generic.BulkDeleteView): queryset = ObjectPermission.objects.all() filterset = filtersets.ObjectPermissionFilterSet table = tables.ObjectPermissionTable + + +# +# Owner groups +# + +@register_model_view(OwnerGroup, 'list', path='', detail=False) +class OwnerGroupListView(generic.ObjectListView): + queryset = OwnerGroup.objects.annotate( + owner_count=count_related(Owner, 'group') + ) + filterset = filtersets.OwnerGroupFilterSet + filterset_form = forms.OwnerGroupFilterForm + table = tables.OwnerGroupTable + + +@register_model_view(OwnerGroup) +class OwnerGroupView(GetRelatedModelsMixin, generic.ObjectView): + queryset = OwnerGroup.objects.all() + template_name = 'users/ownergroup.html' + + def get_extra_context(self, request, instance): + return { + 'related_models': self.get_related_models(request, instance), + } + + +@register_model_view(OwnerGroup, 'add', detail=False) +@register_model_view(OwnerGroup, 'edit') +class OwnerGroupEditView(generic.ObjectEditView): + queryset = OwnerGroup.objects.all() + form = forms.OwnerGroupForm + + +@register_model_view(OwnerGroup, 'delete') +class OwnerGroupDeleteView(generic.ObjectDeleteView): + queryset = OwnerGroup.objects.all() + + +@register_model_view(OwnerGroup, 'bulk_import', path='import', detail=False) +class OwnerGroupBulkImportView(generic.BulkImportView): + queryset = OwnerGroup.objects.all() + model_form = forms.OwnerGroupImportForm + + +@register_model_view(OwnerGroup, 'bulk_edit', path='edit', detail=False) +class OwnerGroupBulkEditView(generic.BulkEditView): + queryset = OwnerGroup.objects.all() + filterset = filtersets.OwnerGroupFilterSet + table = tables.OwnerGroupTable + form = forms.OwnerGroupBulkEditForm + + +@register_model_view(OwnerGroup, 'bulk_rename', path='rename', detail=False) +class OwnerGroupBulkRenameView(generic.BulkRenameView): + queryset = OwnerGroup.objects.all() + + +@register_model_view(OwnerGroup, 'bulk_delete', path='delete', detail=False) +class OwnerGroupBulkDeleteView(generic.BulkDeleteView): + queryset = OwnerGroup.objects.all() + filterset = filtersets.OwnerGroupFilterSet + table = tables.OwnerGroupTable + + +# +# Owners +# + +@register_model_view(Owner, 'list', path='', detail=False) +class OwnerListView(generic.ObjectListView): + queryset = Owner.objects.all() + filterset = filtersets.OwnerFilterSet + filterset_form = forms.OwnerFilterForm + table = tables.OwnerTable + + +@register_model_view(Owner) +class OwnerView(GetRelatedModelsMixin, generic.ObjectView): + queryset = Owner.objects.all() + template_name = 'users/owner.html' + + def get_extra_context(self, request, instance): + return { + 'related_models': self.get_related_models( + request, + instance, + omit=(Group, User), + ), + } + + +@register_model_view(Owner, 'add', detail=False) +@register_model_view(Owner, 'edit') +class OwnerEditView(generic.ObjectEditView): + queryset = Owner.objects.all() + form = forms.OwnerForm + + +@register_model_view(Owner, 'delete') +class OwnerDeleteView(generic.ObjectDeleteView): + queryset = Owner.objects.all() + + +@register_model_view(Owner, 'bulk_import', path='import', detail=False) +class OwnerBulkImportView(generic.BulkImportView): + queryset = Owner.objects.all() + model_form = forms.OwnerImportForm + + +@register_model_view(Owner, 'bulk_edit', path='edit', detail=False) +class OwnerBulkEditView(generic.BulkEditView): + queryset = Owner.objects.all() + filterset = filtersets.OwnerFilterSet + table = tables.OwnerTable + form = forms.OwnerBulkEditForm + + +@register_model_view(Owner, 'bulk_rename', path='rename', detail=False) +class OwnerBulkRenameView(generic.BulkRenameView): + queryset = Owner.objects.all() + + +@register_model_view(Owner, 'bulk_delete', path='delete', detail=False) +class OwnerBulkDeleteView(generic.BulkDeleteView): + queryset = Owner.objects.all() + filterset = filtersets.OwnerFilterSet + table = tables.OwnerTable diff --git a/netbox/utilities/querydict.py b/netbox/utilities/querydict.py index 73d40bfc4..17a0c8c2b 100644 --- a/netbox/utilities/querydict.py +++ b/netbox/utilities/querydict.py @@ -2,7 +2,7 @@ from urllib.parse import urlencode from django.http import QueryDict from django.utils.datastructures import MultiValueDict -from netbox.models import CloningMixin +from netbox.models.features import CloningMixin __all__ = ( 'dict_to_querydict', diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index f00b21d08..c4ef28e26 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -1113,6 +1113,21 @@ class ViewTestCases: """ maxDiff = None + class AdminModelViewTestCase( + GetObjectViewTestCase, + CreateObjectViewTestCase, + EditObjectViewTestCase, + DeleteObjectViewTestCase, + ListObjectsViewTestCase, + BulkImportObjectsViewTestCase, + BulkEditObjectsViewTestCase, + BulkDeleteObjectsViewTestCase, + ): + """ + TestCase suitable for testing all standard View functions for objects which inherit from AdminModel. + """ + maxDiff = None + class DeviceComponentTemplateViewTestCase( EditObjectViewTestCase, DeleteObjectViewTestCase, diff --git a/netbox/virtualization/api/serializers_/clusters.py b/netbox/virtualization/api/serializers_/clusters.py index ff64db1cf..a48af9ce0 100644 --- a/netbox/virtualization/api/serializers_/clusters.py +++ b/netbox/virtualization/api/serializers_/clusters.py @@ -3,7 +3,7 @@ from django.contrib.contenttypes.models import ContentType from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField -from netbox.api.serializers import NetBoxModelSerializer +from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer from tenancy.api.serializers_.tenants import TenantSerializer from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType @@ -16,7 +16,7 @@ __all__ = ( ) -class ClusterTypeSerializer(NetBoxModelSerializer): +class ClusterTypeSerializer(OrganizationalModelSerializer): # Related object counts cluster_count = RelatedObjectCountField('clusters') @@ -24,13 +24,13 @@ class ClusterTypeSerializer(NetBoxModelSerializer): class Meta: model = ClusterType fields = [ - 'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', + 'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'owner', 'tags', 'custom_fields', 'created', 'last_updated', 'cluster_count', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'cluster_count') -class ClusterGroupSerializer(NetBoxModelSerializer): +class ClusterGroupSerializer(OrganizationalModelSerializer): # Related object counts cluster_count = RelatedObjectCountField('clusters') @@ -38,13 +38,13 @@ class ClusterGroupSerializer(NetBoxModelSerializer): class Meta: model = ClusterGroup fields = [ - 'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', + 'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'owner', 'tags', 'custom_fields', 'created', 'last_updated', 'cluster_count', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'cluster_count') -class ClusterSerializer(NetBoxModelSerializer): +class ClusterSerializer(PrimaryModelSerializer): type = ClusterTypeSerializer(nested=True) group = ClusterGroupSerializer(nested=True, required=False, allow_null=True, default=None) status = ChoiceField(choices=ClusterStatusChoices, required=False) @@ -76,7 +76,7 @@ class ClusterSerializer(NetBoxModelSerializer): model = Cluster fields = [ 'id', 'url', 'display_url', 'display', 'name', 'type', 'group', 'status', 'tenant', 'scope_type', - 'scope_id', 'scope', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'scope_id', 'scope', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', 'allocated_vcpus', 'allocated_memory', 'allocated_disk' ] brief_fields = ('id', 'url', 'display', 'name', 'description', 'virtualmachine_count') diff --git a/netbox/virtualization/api/serializers_/virtualmachines.py b/netbox/virtualization/api/serializers_/virtualmachines.py index ed14b0a29..c035a436a 100644 --- a/netbox/virtualization/api/serializers_/virtualmachines.py +++ b/netbox/virtualization/api/serializers_/virtualmachines.py @@ -13,7 +13,7 @@ from ipam.api.serializers_.vlans import VLANSerializer, VLANTranslationPolicySer from ipam.api.serializers_.vrfs import VRFSerializer from ipam.models import VLAN from netbox.api.fields import ChoiceField, SerializedPKRelatedField -from netbox.api.serializers import NetBoxModelSerializer +from netbox.api.serializers import NetBoxModelSerializer, PrimaryModelSerializer from tenancy.api.serializers_.tenants import TenantSerializer from virtualization.choices import * from virtualization.models import VirtualDisk, VirtualMachine, VMInterface @@ -29,7 +29,7 @@ __all__ = ( ) -class VirtualMachineSerializer(NetBoxModelSerializer): +class VirtualMachineSerializer(PrimaryModelSerializer): status = ChoiceField(choices=VirtualMachineStatusChoices, required=False) site = SiteSerializer(nested=True, required=False, allow_null=True, default=None) cluster = ClusterSerializer(nested=True, required=False, allow_null=True, default=None) @@ -51,8 +51,8 @@ class VirtualMachineSerializer(NetBoxModelSerializer): fields = [ 'id', 'url', 'display_url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'serial', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', - 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', - 'interface_count', 'virtual_disk_count', + 'owner', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', + 'last_updated', 'interface_count', 'virtual_disk_count', ] brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 802e34e00..e2ef8cb6a 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -9,8 +9,10 @@ from dcim.models import MACAddress from extras.filtersets import LocalConfigContextFilterSet from extras.models import ConfigTemplate from ipam.filtersets import PrimaryIPFilterSet -from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet +from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet + +from users.filterset_mixins import OwnerFilterMixin from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter from .choices import * from .models import * @@ -39,7 +41,7 @@ class ClusterGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet) fields = ('id', 'name', 'slug', 'description') -class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ScopedFilterSet, ContactModelFilterSet): +class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ScopedFilterSet, ContactModelFilterSet): group_id = django_filters.ModelMultipleChoiceFilter( queryset=ClusterGroup.objects.all(), label=_('Parent group (ID)'), @@ -80,7 +82,7 @@ class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ScopedFilterSet, class VirtualMachineFilterSet( - NetBoxModelFilterSet, + PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet, @@ -235,7 +237,7 @@ class VirtualMachineFilterSet( return queryset.exclude(params) -class VMInterfaceFilterSet(NetBoxModelFilterSet, CommonInterfaceFilterSet): +class VMInterfaceFilterSet(CommonInterfaceFilterSet, OwnerFilterMixin, NetBoxModelFilterSet): cluster_id = django_filters.ModelMultipleChoiceFilter( field_name='virtual_machine__cluster', queryset=Cluster.objects.all(), @@ -297,7 +299,7 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet, CommonInterfaceFilterSet): ) -class VirtualDiskFilterSet(NetBoxModelFilterSet): +class VirtualDiskFilterSet(OwnerFilterMixin, NetBoxModelFilterSet): virtual_machine_id = django_filters.ModelMultipleChoiceFilter( field_name='virtual_machine', queryset=VirtualMachine.objects.all(), diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index 80b665047..092bf576b 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -7,10 +7,11 @@ from dcim.forms.mixins import ScopedBulkEditForm from dcim.models import Device, DeviceRole, Platform, Site from extras.models import ConfigTemplate from ipam.models import VLAN, VLANGroup, VLANTranslationPolicy, VRF -from netbox.forms import NetBoxModelBulkEditForm +from netbox.forms import NetBoxModelBulkEditForm, OrganizationalModelBulkEditForm, PrimaryModelBulkEditForm +from netbox.forms.mixins import OwnerMixin from tenancy.models import Tenant from utilities.forms import BulkRenameForm, add_blank_choice -from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField +from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.rendering import FieldSet from utilities.forms.widgets import BulkEditNullBooleanSelect from virtualization.choices import * @@ -28,13 +29,7 @@ __all__ = ( ) -class ClusterTypeBulkEditForm(NetBoxModelBulkEditForm): - description = forms.CharField( - label=_('Description'), - max_length=200, - required=False - ) - +class ClusterTypeBulkEditForm(OrganizationalModelBulkEditForm): model = ClusterType fieldsets = ( FieldSet('description'), @@ -42,13 +37,7 @@ class ClusterTypeBulkEditForm(NetBoxModelBulkEditForm): nullable_fields = ('description',) -class ClusterGroupBulkEditForm(NetBoxModelBulkEditForm): - description = forms.CharField( - label=_('Description'), - max_length=200, - required=False - ) - +class ClusterGroupBulkEditForm(OrganizationalModelBulkEditForm): model = ClusterGroup fieldsets = ( FieldSet('description'), @@ -56,7 +45,7 @@ class ClusterGroupBulkEditForm(NetBoxModelBulkEditForm): nullable_fields = ('description',) -class ClusterBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm): +class ClusterBulkEditForm(ScopedBulkEditForm, PrimaryModelBulkEditForm): type = DynamicModelChoiceField( label=_('Type'), queryset=ClusterType.objects.all(), @@ -78,12 +67,6 @@ class ClusterBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm): queryset=Tenant.objects.all(), required=False ) - description = forms.CharField( - label=_('Description'), - max_length=200, - required=False - ) - comments = CommentField() model = Cluster fieldsets = ( @@ -95,7 +78,7 @@ class ClusterBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm): ) -class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): +class VirtualMachineBulkEditForm(PrimaryModelBulkEditForm): status = forms.ChoiceField( label=_('Status'), choices=add_blank_choice(VirtualMachineStatusChoices), @@ -155,16 +138,10 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): required=False, label=_('Disk (MB)') ) - description = forms.CharField( - label=_('Description'), - max_length=200, - required=False - ) config_template = DynamicModelChoiceField( queryset=ConfigTemplate.objects.all(), required=False ) - comments = CommentField() model = VirtualMachine fieldsets = ( @@ -177,7 +154,7 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): ) -class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): +class VMInterfaceBulkEditForm(OwnerMixin, NetBoxModelBulkEditForm): virtual_machine = forms.ModelChoiceField( label=_('Virtual machine'), queryset=VirtualMachine.objects.all(), @@ -311,7 +288,7 @@ class VMInterfaceBulkRenameForm(BulkRenameForm): ) -class VirtualDiskBulkEditForm(NetBoxModelBulkEditForm): +class VirtualDiskBulkEditForm(OwnerMixin, NetBoxModelBulkEditForm): virtual_machine = forms.ModelChoiceField( label=_('Virtual machine'), queryset=VirtualMachine.objects.all(), diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index 6b5b62d11..67f39b6f5 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -5,9 +5,11 @@ from dcim.forms.mixins import ScopedImportForm from dcim.models import Device, DeviceRole, Platform, Site from extras.models import ConfigTemplate from ipam.models import VRF -from netbox.forms import NetBoxModelImportForm +from netbox.forms import ( + NetBoxModelImportForm, OrganizationalModelImportForm, OwnerCSVMixin, PrimaryModelImportForm, +) from tenancy.models import Tenant -from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField +from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField from virtualization.choices import * from virtualization.models import * @@ -21,23 +23,21 @@ __all__ = ( ) -class ClusterTypeImportForm(NetBoxModelImportForm): - slug = SlugField() +class ClusterTypeImportForm(OrganizationalModelImportForm): class Meta: model = ClusterType - fields = ('name', 'slug', 'description', 'tags') + fields = ('name', 'slug', 'description', 'owner', 'tags') -class ClusterGroupImportForm(NetBoxModelImportForm): - slug = SlugField() +class ClusterGroupImportForm(OrganizationalModelImportForm): class Meta: model = ClusterGroup - fields = ('name', 'slug', 'description', 'tags') + fields = ('name', 'slug', 'description', 'owner', 'tags') -class ClusterImportForm(ScopedImportForm, NetBoxModelImportForm): +class ClusterImportForm(ScopedImportForm, PrimaryModelImportForm): type = CSVModelChoiceField( label=_('Type'), queryset=ClusterType.objects.all(), @@ -74,14 +74,15 @@ class ClusterImportForm(ScopedImportForm, NetBoxModelImportForm): class Meta: model = Cluster fields = ( - 'name', 'type', 'group', 'status', 'scope_type', 'scope_id', 'tenant', 'description', 'comments', 'tags', + 'name', 'type', 'group', 'status', 'scope_type', 'scope_id', 'tenant', 'description', 'owner', 'comments', + 'tags', ) labels = { 'scope_id': _('Scope ID'), } -class VirtualMachineImportForm(NetBoxModelImportForm): +class VirtualMachineImportForm(PrimaryModelImportForm): status = CSVChoiceField( label=_('Status'), choices=VirtualMachineStatusChoices, @@ -143,11 +144,11 @@ class VirtualMachineImportForm(NetBoxModelImportForm): model = VirtualMachine fields = ( 'name', 'status', 'role', 'site', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk', - 'description', 'serial', 'config_template', 'comments', 'tags', + 'description', 'serial', 'config_template', 'comments', 'owner', 'tags', ) -class VMInterfaceImportForm(NetBoxModelImportForm): +class VMInterfaceImportForm(OwnerCSVMixin, NetBoxModelImportForm): virtual_machine = CSVModelChoiceField( label=_('Virtual machine'), queryset=VirtualMachine.objects.all(), @@ -185,7 +186,7 @@ class VMInterfaceImportForm(NetBoxModelImportForm): model = VMInterface fields = ( 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mtu', 'description', 'mode', - 'vrf', 'tags' + 'vrf', 'owner', 'tags' ) def __init__(self, data=None, *args, **kwargs): @@ -208,7 +209,7 @@ class VMInterfaceImportForm(NetBoxModelImportForm): return self.cleaned_data['enabled'] -class VirtualDiskImportForm(NetBoxModelImportForm): +class VirtualDiskImportForm(OwnerCSVMixin, NetBoxModelImportForm): virtual_machine = CSVModelChoiceField( label=_('Virtual machine'), queryset=VirtualMachine.objects.all(), @@ -218,5 +219,5 @@ class VirtualDiskImportForm(NetBoxModelImportForm): class Meta: model = VirtualDisk fields = ( - 'virtual_machine', 'name', 'size', 'description', 'tags' + 'virtual_machine', 'name', 'size', 'description', 'owner', 'tags' ) diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index b081fa8c6..3e0db175e 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -6,10 +6,11 @@ from dcim.models import Device, DeviceRole, Location, Platform, Region, Site, Si from extras.forms import LocalConfigContextFilterForm from extras.models import ConfigTemplate from ipam.models import VRF, VLANTranslationPolicy -from netbox.forms import NetBoxModelFilterSetForm +from netbox.forms import NetBoxModelFilterSetForm, OrganizationalModelFilterSetForm, PrimaryModelFilterSetForm from tenancy.forms import ContactModelFilterForm, TenancyFilterForm +from users.models import Owner from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES -from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField +from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField from utilities.forms.rendering import FieldSet from virtualization.choices import * from virtualization.models import * @@ -25,24 +26,27 @@ __all__ = ( ) -class ClusterTypeFilterForm(NetBoxModelFilterSetForm): +class ClusterTypeFilterForm(OrganizationalModelFilterSetForm): model = ClusterType + fieldsets = ( + FieldSet('q', 'filter_id', 'tag', 'owner_id'), + ) tag = TagFilterField(model) -class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): +class ClusterGroupFilterForm(ContactModelFilterForm, OrganizationalModelFilterSetForm): model = ClusterGroup tag = TagFilterField(model) fieldsets = ( - FieldSet('q', 'filter_id', 'tag'), + FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), ) -class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): +class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm): model = Cluster fieldsets = ( - FieldSet('q', 'filter_id', 'tag'), + FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('group_id', 'type_id', 'status', name=_('Attributes')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), @@ -97,11 +101,11 @@ class VirtualMachineFilterForm( LocalConfigContextFilterForm, TenancyFilterForm, ContactModelFilterForm, - NetBoxModelFilterSetForm + PrimaryModelFilterSetForm ): model = VirtualMachine fieldsets = ( - FieldSet('q', 'filter_id', 'tag'), + FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id', name=_('Cluster')), FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), FieldSet( @@ -199,7 +203,7 @@ class VirtualMachineFilterForm( class VMInterfaceFilterForm(NetBoxModelFilterSetForm): model = VMInterface fieldsets = ( - FieldSet('q', 'filter_id', 'tag'), + FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('cluster_id', 'virtual_machine_id', name=_('Virtual Machine')), FieldSet('enabled', name=_('Attributes')), FieldSet('vrf_id', 'l2vpn_id', 'mac_address', name=_('Addressing')), @@ -250,13 +254,18 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm): required=False, label=_('VLAN Translation Policy') ) + owner_id = DynamicModelChoiceField( + queryset=Owner.objects.all(), + required=False, + label=_('Owner'), + ) tag = TagFilterField(model) class VirtualDiskFilterForm(NetBoxModelFilterSetForm): model = VirtualDisk fieldsets = ( - FieldSet('q', 'filter_id', 'tag'), + FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('virtual_machine_id', name=_('Virtual Machine')), FieldSet('size', name=_('Attributes')), ) @@ -270,4 +279,9 @@ class VirtualDiskFilterForm(NetBoxModelFilterSetForm): required=False, min_value=1 ) + owner_id = DynamicModelChoiceField( + queryset=Owner.objects.all(), + required=False, + label=_('Owner'), + ) tag = TagFilterField(model) diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index 82f8d8315..fa4966b2b 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -10,12 +10,11 @@ from dcim.models import Device, DeviceRole, MACAddress, Platform, Rack, Region, from extras.models import ConfigTemplate from ipam.choices import VLANQinQRoleChoices from ipam.models import IPAddress, VLAN, VLANGroup, VLANTranslationPolicy, VRF -from netbox.forms import NetBoxModelForm +from netbox.forms import NetBoxModelForm, OrganizationalModelForm, PrimaryModelForm +from netbox.forms.mixins import OwnerMixin from tenancy.forms import TenancyForm from utilities.forms import ConfirmationForm -from utilities.forms.fields import ( - CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField, -) +from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField from utilities.forms.rendering import FieldSet from utilities.forms.widgets import HTMXSelect from virtualization.models import * @@ -32,9 +31,7 @@ __all__ = ( ) -class ClusterTypeForm(NetBoxModelForm): - slug = SlugField() - +class ClusterTypeForm(OrganizationalModelForm): fieldsets = ( FieldSet('name', 'slug', 'description', 'tags', name=_('Cluster Type')), ) @@ -42,13 +39,11 @@ class ClusterTypeForm(NetBoxModelForm): class Meta: model = ClusterType fields = ( - 'name', 'slug', 'description', 'tags', + 'name', 'slug', 'description', 'owner', 'tags', ) -class ClusterGroupForm(NetBoxModelForm): - slug = SlugField() - +class ClusterGroupForm(OrganizationalModelForm): fieldsets = ( FieldSet('name', 'slug', 'description', 'tags', name=_('Cluster Group')), ) @@ -56,11 +51,11 @@ class ClusterGroupForm(NetBoxModelForm): class Meta: model = ClusterGroup fields = ( - 'name', 'slug', 'description', 'tags', + 'name', 'slug', 'description', 'owner', 'tags', ) -class ClusterForm(TenancyForm, ScopedForm, NetBoxModelForm): +class ClusterForm(TenancyForm, ScopedForm, PrimaryModelForm): type = DynamicModelChoiceField( label=_('Type'), queryset=ClusterType.objects.all(), @@ -72,7 +67,6 @@ class ClusterForm(TenancyForm, ScopedForm, NetBoxModelForm): required=False, quick_add=True ) - comments = CommentField() fieldsets = ( FieldSet('name', 'type', 'group', 'status', 'description', 'tags', name=_('Cluster')), @@ -83,7 +77,7 @@ class ClusterForm(TenancyForm, ScopedForm, NetBoxModelForm): class Meta: model = Cluster fields = ( - 'name', 'type', 'group', 'status', 'tenant', 'scope_type', 'description', 'comments', 'tags', + 'name', 'type', 'group', 'status', 'tenant', 'scope_type', 'description', 'owner', 'comments', 'tags', ) @@ -173,7 +167,7 @@ class ClusterRemoveDevicesForm(ConfirmationForm): ) -class VirtualMachineForm(TenancyForm, NetBoxModelForm): +class VirtualMachineForm(TenancyForm, PrimaryModelForm): site = DynamicModelChoiceField( label=_('Site'), queryset=Site.objects.all(), @@ -221,7 +215,6 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): required=False, label=_('Config template') ) - comments = CommentField() fieldsets = ( FieldSet('name', 'role', 'status', 'description', 'serial', 'tags', name=_('Virtual Machine')), @@ -236,7 +229,7 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): model = VirtualMachine fields = [ 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4', - 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'serial', 'comments', 'tags', + 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'serial', 'owner', 'comments', 'tags', 'local_context_data', 'config_template', ] @@ -288,7 +281,7 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): # Virtual machine components # -class VMComponentForm(NetBoxModelForm): +class VMComponentForm(OwnerMixin, NetBoxModelForm): virtual_machine = DynamicModelChoiceField( label=_('Virtual machine'), queryset=VirtualMachine.objects.all(), @@ -387,7 +380,7 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm): fields = [ 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mtu', 'description', 'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'primary_mac_address', - 'tags', + 'owner', 'tags', ] labels = { 'mode': _('802.1Q Mode'), @@ -406,5 +399,5 @@ class VirtualDiskForm(VMComponentForm): class Meta: model = VirtualDisk fields = [ - 'virtual_machine', 'name', 'size', 'description', 'tags', + 'virtual_machine', 'name', 'size', 'description', 'owner', 'tags', ] diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py index ba20e6844..59323e7e5 100644 --- a/netbox/virtualization/graphql/types.py +++ b/netbox/virtualization/graphql/types.py @@ -6,7 +6,8 @@ import strawberry_django from extras.graphql.mixins import ConfigContextMixin, ContactsMixin from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin from netbox.graphql.scalars import BigInt -from netbox.graphql.types import OrganizationalObjectType, NetBoxObjectType +from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType, NetBoxObjectType +from users.graphql.mixins import OwnerMixin from virtualization import models from .filters import * @@ -36,7 +37,7 @@ __all__ = ( @strawberry.type -class ComponentType(NetBoxObjectType): +class ComponentType(OwnerMixin, NetBoxObjectType): """ Base type for device/VM components """ @@ -49,7 +50,7 @@ class ComponentType(NetBoxObjectType): filters=ClusterFilter, pagination=True ) -class ClusterType(ContactsMixin, VLANGroupsMixin, NetBoxObjectType): +class ClusterType(ContactsMixin, VLANGroupsMixin, PrimaryObjectType): type: Annotated["ClusterTypeType", strawberry.lazy('virtualization.graphql.types')] | None group: Annotated["ClusterGroupType", strawberry.lazy('virtualization.graphql.types')] | None tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None @@ -94,7 +95,7 @@ class ClusterTypeType(OrganizationalObjectType): filters=VirtualMachineFilter, pagination=True ) -class VirtualMachineType(ConfigContextMixin, ContactsMixin, NetBoxObjectType): +class VirtualMachineType(ConfigContextMixin, ContactsMixin, PrimaryObjectType): interface_count: BigInt virtual_disk_count: BigInt interface_count: BigInt diff --git a/netbox/virtualization/migrations/0049_owner.py b/netbox/virtualization/migrations/0049_owner.py new file mode 100644 index 000000000..630b7fc80 --- /dev/null +++ b/netbox/virtualization/migrations/0049_owner.py @@ -0,0 +1,54 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('users', '0015_owner'), + ('virtualization', '0048_populate_mac_addresses'), + ] + + operations = [ + migrations.AddField( + model_name='cluster', + name='owner', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + ), + ), + migrations.AddField( + model_name='clustergroup', + name='owner', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + ), + ), + migrations.AddField( + model_name='clustertype', + name='owner', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + ), + ), + migrations.AddField( + model_name='virtualdisk', + name='owner', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + ), + ), + migrations.AddField( + model_name='virtualmachine', + name='owner', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + ), + ), + migrations.AddField( + model_name='vminterface', + name='owner', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + ), + ), + ] diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py index aca2a7dbd..de6fde745 100644 --- a/netbox/virtualization/models/virtualmachines.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -15,6 +15,7 @@ from extras.querysets import ConfigContextModelQuerySet from netbox.config import get_config from netbox.models import NetBoxModel, PrimaryModel from netbox.models.features import ContactsMixin, ImageAttachmentsMixin +from netbox.models.mixins import OwnerMixin from utilities.fields import CounterCacheField, NaturalOrderingField from utilities.ordering import naturalize_interface from utilities.query_functions import CollateAsChar @@ -263,7 +264,7 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co # -class ComponentModel(NetBoxModel): +class ComponentModel(OwnerMixin, NetBoxModel): """ An abstract model inherited by any model which has a parent VirtualMachine. """ diff --git a/netbox/virtualization/tables/clusters.py b/netbox/virtualization/tables/clusters.py index 665f8fa8b..6053701b1 100644 --- a/netbox/virtualization/tables/clusters.py +++ b/netbox/virtualization/tables/clusters.py @@ -1,10 +1,10 @@ -from django.utils.translation import gettext_lazy as _ import django_tables2 as tables +from django.utils.translation import gettext_lazy as _ + +from netbox.tables import OrganizationalModelTable, PrimaryModelTable, columns from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin from virtualization.models import Cluster, ClusterGroup, ClusterType -from netbox.tables import NetBoxTable, columns - __all__ = ( 'ClusterTable', 'ClusterGroupTable', @@ -12,7 +12,7 @@ __all__ = ( ) -class ClusterTypeTable(NetBoxTable): +class ClusterTypeTable(OrganizationalModelTable): name = tables.Column( verbose_name=_('Name'), linkify=True @@ -26,7 +26,7 @@ class ClusterTypeTable(NetBoxTable): url_name='virtualization:clustertype_list' ) - class Meta(NetBoxTable.Meta): + class Meta(OrganizationalModelTable.Meta): model = ClusterType fields = ( 'pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'created', 'last_updated', 'tags', 'actions', @@ -34,7 +34,7 @@ class ClusterTypeTable(NetBoxTable): default_columns = ('pk', 'name', 'cluster_count', 'description') -class ClusterGroupTable(ContactsColumnMixin, NetBoxTable): +class ClusterGroupTable(ContactsColumnMixin, OrganizationalModelTable): name = tables.Column( verbose_name=_('Name'), linkify=True @@ -48,7 +48,7 @@ class ClusterGroupTable(ContactsColumnMixin, NetBoxTable): url_name='virtualization:clustergroup_list' ) - class Meta(NetBoxTable.Meta): + class Meta(OrganizationalModelTable.Meta): model = ClusterGroup fields = ( 'pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'contacts', 'tags', 'created', 'last_updated', @@ -57,7 +57,7 @@ class ClusterGroupTable(ContactsColumnMixin, NetBoxTable): default_columns = ('pk', 'name', 'cluster_count', 'description') -class ClusterTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): +class ClusterTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable): name = tables.Column( verbose_name=_('Name'), linkify=True @@ -91,14 +91,11 @@ class ClusterTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): url_params={'cluster_id': 'pk'}, verbose_name=_('VMs') ) - comments = columns.MarkdownColumn( - verbose_name=_('Comments'), - ) tags = columns.TagColumn( url_name='virtualization:cluster_list' ) - class Meta(NetBoxTable.Meta): + class Meta(PrimaryModelTable.Meta): model = Cluster fields = ( 'pk', 'id', 'name', 'type', 'group', 'status', 'tenant', 'tenant_group', 'scope', 'scope_type', diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index d56fe668a..fcb9017df 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -2,7 +2,7 @@ import django_tables2 as tables from django.utils.translation import gettext_lazy as _ from dcim.tables.devices import BaseInterfaceTable -from netbox.tables import NetBoxTable, columns +from netbox.tables import NetBoxTable, PrimaryModelTable, columns from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin from utilities.templatetags.helpers import humanize_disk_megabytes from virtualization.models import VirtualDisk, VirtualMachine, VMInterface @@ -21,7 +21,7 @@ __all__ = ( # Virtual machines # -class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): +class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable): name = tables.Column( verbose_name=_('Name'), linkify=True @@ -48,9 +48,6 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable) linkify=True, verbose_name=_('Platform') ) - comments = columns.MarkdownColumn( - verbose_name=_('Comments'), - ) primary_ip4 = tables.Column( linkify=True, verbose_name=_('IPv4 Address') @@ -81,7 +78,7 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable) verbose_name=_('Disk'), ) - class Meta(NetBoxTable.Meta): + class Meta(PrimaryModelTable.Meta): model = VirtualMachine fields = ( 'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'tenant_group', 'vcpus', diff --git a/netbox/vpn/api/serializers_/crypto.py b/netbox/vpn/api/serializers_/crypto.py index 50085884b..83b10af15 100644 --- a/netbox/vpn/api/serializers_/crypto.py +++ b/netbox/vpn/api/serializers_/crypto.py @@ -1,5 +1,5 @@ from netbox.api.fields import ChoiceField, SerializedPKRelatedField -from netbox.api.serializers import NetBoxModelSerializer +from netbox.api.serializers import PrimaryModelSerializer from vpn.choices import * from vpn.models import IKEPolicy, IKEProposal, IPSecPolicy, IPSecProfile, IPSecProposal @@ -12,7 +12,7 @@ __all__ = ( ) -class IKEProposalSerializer(NetBoxModelSerializer): +class IKEProposalSerializer(PrimaryModelSerializer): authentication_method = ChoiceField( choices=AuthenticationMethodChoices ) @@ -31,13 +31,13 @@ class IKEProposalSerializer(NetBoxModelSerializer): model = IKEProposal fields = ( 'id', 'url', 'display_url', 'display', 'name', 'description', 'authentication_method', - 'encryption_algorithm', 'authentication_algorithm', 'group', 'sa_lifetime', 'comments', 'tags', + 'encryption_algorithm', 'authentication_algorithm', 'group', 'sa_lifetime', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ) brief_fields = ('id', 'url', 'display', 'name', 'description') -class IKEPolicySerializer(NetBoxModelSerializer): +class IKEPolicySerializer(PrimaryModelSerializer): version = ChoiceField( choices=IKEVersionChoices ) @@ -57,12 +57,12 @@ class IKEPolicySerializer(NetBoxModelSerializer): model = IKEPolicy fields = ( 'id', 'url', 'display_url', 'display', 'name', 'description', 'version', 'mode', 'proposals', - 'preshared_key', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'preshared_key', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ) brief_fields = ('id', 'url', 'display', 'name', 'description') -class IPSecProposalSerializer(NetBoxModelSerializer): +class IPSecProposalSerializer(PrimaryModelSerializer): encryption_algorithm = ChoiceField( choices=EncryptionAlgorithmChoices, required=False @@ -76,13 +76,13 @@ class IPSecProposalSerializer(NetBoxModelSerializer): model = IPSecProposal fields = ( 'id', 'url', 'display_url', 'display', 'name', 'description', 'encryption_algorithm', - 'authentication_algorithm', 'sa_lifetime_seconds', 'sa_lifetime_data', 'comments', 'tags', 'custom_fields', - 'created', 'last_updated', + 'authentication_algorithm', 'sa_lifetime_seconds', 'sa_lifetime_data', 'owner', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', ) brief_fields = ('id', 'url', 'display', 'name', 'description') -class IPSecPolicySerializer(NetBoxModelSerializer): +class IPSecPolicySerializer(PrimaryModelSerializer): proposals = SerializedPKRelatedField( queryset=IPSecProposal.objects.all(), serializer=IPSecProposalSerializer, @@ -98,13 +98,13 @@ class IPSecPolicySerializer(NetBoxModelSerializer): class Meta: model = IPSecPolicy fields = ( - 'id', 'url', 'display_url', 'display', 'name', 'description', 'proposals', 'pfs_group', 'comments', 'tags', - 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display_url', 'display', 'name', 'description', 'proposals', 'pfs_group', 'owner', 'comments', + 'tags', 'custom_fields', 'created', 'last_updated', ) brief_fields = ('id', 'url', 'display', 'name', 'description') -class IPSecProfileSerializer(NetBoxModelSerializer): +class IPSecProfileSerializer(PrimaryModelSerializer): mode = ChoiceField( choices=IPSecModeChoices ) @@ -118,7 +118,7 @@ class IPSecProfileSerializer(NetBoxModelSerializer): class Meta: model = IPSecProfile fields = ( - 'id', 'url', 'display_url', 'display', 'name', 'description', 'mode', 'ike_policy', 'ipsec_policy', + 'id', 'url', 'display_url', 'display', 'name', 'description', 'mode', 'ike_policy', 'ipsec_policy', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ) brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/vpn/api/serializers_/l2vpn.py b/netbox/vpn/api/serializers_/l2vpn.py index f7c27113a..f9e9a9a97 100644 --- a/netbox/vpn/api/serializers_/l2vpn.py +++ b/netbox/vpn/api/serializers_/l2vpn.py @@ -5,7 +5,7 @@ from rest_framework import serializers from ipam.api.serializers_.vrfs import RouteTargetSerializer from ipam.models import RouteTarget from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField -from netbox.api.serializers import NetBoxModelSerializer +from netbox.api.serializers import NetBoxModelSerializer, PrimaryModelSerializer from tenancy.api.serializers_.tenants import TenantSerializer from utilities.api import get_serializer_for_model from vpn.choices import * @@ -17,7 +17,7 @@ __all__ = ( ) -class L2VPNSerializer(NetBoxModelSerializer): +class L2VPNSerializer(PrimaryModelSerializer): type = ChoiceField(choices=L2VPNTypeChoices, required=False) import_targets = SerializedPKRelatedField( queryset=RouteTarget.objects.all(), @@ -40,7 +40,8 @@ class L2VPNSerializer(NetBoxModelSerializer): model = L2VPN fields = [ 'id', 'url', 'display_url', 'display', 'identifier', 'name', 'slug', 'type', 'status', 'import_targets', - 'export_targets', 'description', 'comments', 'tenant', 'tags', 'custom_fields', 'created', 'last_updated' + 'export_targets', 'description', 'owner', 'comments', 'tenant', 'tags', 'custom_fields', 'created', + 'last_updated', ] brief_fields = ('id', 'url', 'display', 'identifier', 'name', 'slug', 'type', 'description') diff --git a/netbox/vpn/api/serializers_/tunnels.py b/netbox/vpn/api/serializers_/tunnels.py index a89766d3f..dfeb0339f 100644 --- a/netbox/vpn/api/serializers_/tunnels.py +++ b/netbox/vpn/api/serializers_/tunnels.py @@ -4,7 +4,7 @@ from rest_framework import serializers from ipam.api.serializers_.ip import IPAddressSerializer from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField -from netbox.api.serializers import NetBoxModelSerializer +from netbox.api.serializers import NetBoxModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer from tenancy.api.serializers_.tenants import TenantSerializer from utilities.api import get_serializer_for_model from vpn.choices import * @@ -22,7 +22,7 @@ __all__ = ( # Tunnels # -class TunnelGroupSerializer(NetBoxModelSerializer): +class TunnelGroupSerializer(OrganizationalModelSerializer): # Related object counts tunnel_count = RelatedObjectCountField('tunnels') @@ -30,13 +30,13 @@ class TunnelGroupSerializer(NetBoxModelSerializer): class Meta: model = TunnelGroup fields = [ - 'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', + 'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'owner', 'tags', 'custom_fields', 'created', 'last_updated', 'tunnel_count', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'tunnel_count') -class TunnelSerializer(NetBoxModelSerializer): +class TunnelSerializer(PrimaryModelSerializer): status = ChoiceField( choices=TunnelStatusChoices ) @@ -67,8 +67,8 @@ class TunnelSerializer(NetBoxModelSerializer): model = Tunnel fields = ( 'id', 'url', 'display_url', 'display', 'name', 'status', 'group', 'encapsulation', 'ipsec_profile', - 'tenant', 'tunnel_id', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', - 'terminations_count', + 'tenant', 'tunnel_id', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created', + 'last_updated', 'terminations_count', ) brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/vpn/filtersets.py b/netbox/vpn/filtersets.py index d35831e2f..9010c5e2c 100644 --- a/netbox/vpn/filtersets.py +++ b/netbox/vpn/filtersets.py @@ -4,7 +4,7 @@ from django.utils.translation import gettext as _ from dcim.models import Device, Interface from ipam.models import IPAddress, RouteTarget, VLAN -from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet +from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter from virtualization.models import VirtualMachine, VMInterface @@ -32,7 +32,7 @@ class TunnelGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): fields = ('id', 'name', 'slug', 'description') -class TunnelFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): +class TunnelFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet): status = django_filters.MultipleChoiceFilter( choices=TunnelStatusChoices ) @@ -123,7 +123,7 @@ class TunnelTerminationFilterSet(NetBoxModelFilterSet): fields = ('id', 'termination_id') -class IKEProposalFilterSet(NetBoxModelFilterSet): +class IKEProposalFilterSet(PrimaryModelFilterSet): ike_policy_id = django_filters.ModelMultipleChoiceFilter( field_name='ike_policies', queryset=IKEPolicy.objects.all(), @@ -162,7 +162,7 @@ class IKEProposalFilterSet(NetBoxModelFilterSet): ) -class IKEPolicyFilterSet(NetBoxModelFilterSet): +class IKEPolicyFilterSet(PrimaryModelFilterSet): version = django_filters.MultipleChoiceFilter( choices=IKEVersionChoices ) @@ -193,7 +193,7 @@ class IKEPolicyFilterSet(NetBoxModelFilterSet): ) -class IPSecProposalFilterSet(NetBoxModelFilterSet): +class IPSecProposalFilterSet(PrimaryModelFilterSet): ipsec_policy_id = django_filters.ModelMultipleChoiceFilter( field_name='ipsec_policies', queryset=IPSecPolicy.objects.all(), @@ -226,7 +226,7 @@ class IPSecProposalFilterSet(NetBoxModelFilterSet): ) -class IPSecPolicyFilterSet(NetBoxModelFilterSet): +class IPSecPolicyFilterSet(PrimaryModelFilterSet): pfs_group = django_filters.MultipleChoiceFilter( choices=DHGroupChoices ) @@ -254,7 +254,7 @@ class IPSecPolicyFilterSet(NetBoxModelFilterSet): ) -class IPSecProfileFilterSet(NetBoxModelFilterSet): +class IPSecProfileFilterSet(PrimaryModelFilterSet): mode = django_filters.MultipleChoiceFilter( choices=IPSecModeChoices ) @@ -293,7 +293,7 @@ class IPSecProfileFilterSet(NetBoxModelFilterSet): ) -class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): +class L2VPNFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet): type = django_filters.MultipleChoiceFilter( choices=L2VPNTypeChoices, null_value=None diff --git a/netbox/vpn/forms/bulk_edit.py b/netbox/vpn/forms/bulk_edit.py index 700dadb70..487ca51c9 100644 --- a/netbox/vpn/forms/bulk_edit.py +++ b/netbox/vpn/forms/bulk_edit.py @@ -1,10 +1,10 @@ from django import forms from django.utils.translation import gettext_lazy as _ -from netbox.forms import NetBoxModelBulkEditForm +from netbox.forms import NetBoxModelBulkEditForm, OrganizationalModelBulkEditForm, PrimaryModelBulkEditForm from tenancy.models import Tenant from utilities.forms import add_blank_choice -from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField +from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.rendering import FieldSet from vpn.choices import * from vpn.models import * @@ -23,18 +23,12 @@ __all__ = ( ) -class TunnelGroupBulkEditForm(NetBoxModelBulkEditForm): - description = forms.CharField( - label=_('Description'), - max_length=200, - required=False - ) - +class TunnelGroupBulkEditForm(OrganizationalModelBulkEditForm): model = TunnelGroup nullable_fields = ('description',) -class TunnelBulkEditForm(NetBoxModelBulkEditForm): +class TunnelBulkEditForm(PrimaryModelBulkEditForm): status = forms.ChoiceField( label=_('Status'), choices=add_blank_choice(TunnelStatusChoices), @@ -60,16 +54,10 @@ class TunnelBulkEditForm(NetBoxModelBulkEditForm): queryset=Tenant.objects.all(), required=False ) - description = forms.CharField( - label=_('Description'), - max_length=200, - required=False - ) tunnel_id = forms.IntegerField( label=_('Tunnel ID'), required=False ) - comments = CommentField() model = Tunnel fieldsets = ( @@ -92,7 +80,7 @@ class TunnelTerminationBulkEditForm(NetBoxModelBulkEditForm): model = TunnelTermination -class IKEProposalBulkEditForm(NetBoxModelBulkEditForm): +class IKEProposalBulkEditForm(PrimaryModelBulkEditForm): authentication_method = forms.ChoiceField( label=_('Authentication method'), choices=add_blank_choice(AuthenticationMethodChoices), @@ -117,12 +105,6 @@ class IKEProposalBulkEditForm(NetBoxModelBulkEditForm): label=_('SA lifetime'), required=False ) - description = forms.CharField( - label=_('Description'), - max_length=200, - required=False - ) - comments = CommentField() model = IKEProposal fieldsets = ( @@ -136,7 +118,7 @@ class IKEProposalBulkEditForm(NetBoxModelBulkEditForm): ) -class IKEPolicyBulkEditForm(NetBoxModelBulkEditForm): +class IKEPolicyBulkEditForm(PrimaryModelBulkEditForm): version = forms.ChoiceField( label=_('Version'), choices=add_blank_choice(IKEVersionChoices), @@ -151,12 +133,6 @@ class IKEPolicyBulkEditForm(NetBoxModelBulkEditForm): label=_('Pre-shared key'), required=False ) - description = forms.CharField( - label=_('Description'), - max_length=200, - required=False - ) - comments = CommentField() model = IKEPolicy fieldsets = ( @@ -167,7 +143,7 @@ class IKEPolicyBulkEditForm(NetBoxModelBulkEditForm): ) -class IPSecProposalBulkEditForm(NetBoxModelBulkEditForm): +class IPSecProposalBulkEditForm(PrimaryModelBulkEditForm): encryption_algorithm = forms.ChoiceField( label=_('Encryption algorithm'), choices=add_blank_choice(EncryptionAlgorithmChoices), @@ -186,12 +162,6 @@ class IPSecProposalBulkEditForm(NetBoxModelBulkEditForm): label=_('SA lifetime (KB)'), required=False ) - description = forms.CharField( - label=_('Description'), - max_length=200, - required=False - ) - comments = CommentField() model = IPSecProposal fieldsets = ( @@ -205,18 +175,12 @@ class IPSecProposalBulkEditForm(NetBoxModelBulkEditForm): ) -class IPSecPolicyBulkEditForm(NetBoxModelBulkEditForm): +class IPSecPolicyBulkEditForm(PrimaryModelBulkEditForm): pfs_group = forms.ChoiceField( label=_('PFS group'), choices=add_blank_choice(DHGroupChoices), required=False ) - description = forms.CharField( - label=_('Description'), - max_length=200, - required=False - ) - comments = CommentField() model = IPSecPolicy fieldsets = ( @@ -227,7 +191,7 @@ class IPSecPolicyBulkEditForm(NetBoxModelBulkEditForm): ) -class IPSecProfileBulkEditForm(NetBoxModelBulkEditForm): +class IPSecProfileBulkEditForm(PrimaryModelBulkEditForm): mode = forms.ChoiceField( label=_('Mode'), choices=add_blank_choice(IPSecModeChoices), @@ -243,12 +207,6 @@ class IPSecProfileBulkEditForm(NetBoxModelBulkEditForm): queryset=IPSecPolicy.objects.all(), required=False ) - description = forms.CharField( - label=_('Description'), - max_length=200, - required=False - ) - comments = CommentField() model = IPSecProfile fieldsets = ( @@ -259,7 +217,7 @@ class IPSecProfileBulkEditForm(NetBoxModelBulkEditForm): ) -class L2VPNBulkEditForm(NetBoxModelBulkEditForm): +class L2VPNBulkEditForm(PrimaryModelBulkEditForm): status = forms.ChoiceField( label=_('Status'), choices=L2VPNStatusChoices, @@ -274,12 +232,6 @@ class L2VPNBulkEditForm(NetBoxModelBulkEditForm): queryset=Tenant.objects.all(), required=False ) - description = forms.CharField( - label=_('Description'), - max_length=200, - required=False - ) - comments = CommentField() model = L2VPN fieldsets = ( diff --git a/netbox/vpn/forms/bulk_import.py b/netbox/vpn/forms/bulk_import.py index 96744887e..1b6769fad 100644 --- a/netbox/vpn/forms/bulk_import.py +++ b/netbox/vpn/forms/bulk_import.py @@ -3,9 +3,9 @@ from django.utils.translation import gettext_lazy as _ from dcim.models import Device, Interface from ipam.models import IPAddress, VLAN -from netbox.forms import NetBoxModelImportForm +from netbox.forms import NetBoxModelImportForm, OrganizationalModelImportForm, PrimaryModelImportForm from tenancy.models import Tenant -from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField +from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField from virtualization.models import VirtualMachine, VMInterface from vpn.choices import * from vpn.models import * @@ -24,15 +24,14 @@ __all__ = ( ) -class TunnelGroupImportForm(NetBoxModelImportForm): - slug = SlugField() +class TunnelGroupImportForm(OrganizationalModelImportForm): class Meta: model = TunnelGroup - fields = ('name', 'slug', 'description', 'tags') + fields = ('name', 'slug', 'description', 'owner', 'tags') -class TunnelImportForm(NetBoxModelImportForm): +class TunnelImportForm(PrimaryModelImportForm): status = CSVChoiceField( label=_('Status'), choices=TunnelStatusChoices, @@ -67,7 +66,7 @@ class TunnelImportForm(NetBoxModelImportForm): model = Tunnel fields = ( 'name', 'status', 'group', 'encapsulation', 'ipsec_profile', 'tenant', 'tunnel_id', 'description', - 'comments', 'tags', + 'owner', 'comments', 'tags', ) @@ -140,7 +139,7 @@ class TunnelTerminationImportForm(NetBoxModelImportForm): return super().save(*args, **kwargs) -class IKEProposalImportForm(NetBoxModelImportForm): +class IKEProposalImportForm(PrimaryModelImportForm): authentication_method = CSVChoiceField( label=_('Authentication method'), choices=AuthenticationMethodChoices @@ -163,11 +162,11 @@ class IKEProposalImportForm(NetBoxModelImportForm): model = IKEProposal fields = ( 'name', 'description', 'authentication_method', 'encryption_algorithm', 'authentication_algorithm', - 'group', 'sa_lifetime', 'comments', 'tags', + 'group', 'sa_lifetime', 'owner', 'comments', 'tags', ) -class IKEPolicyImportForm(NetBoxModelImportForm): +class IKEPolicyImportForm(PrimaryModelImportForm): version = CSVChoiceField( label=_('Version'), choices=IKEVersionChoices @@ -186,11 +185,11 @@ class IKEPolicyImportForm(NetBoxModelImportForm): class Meta: model = IKEPolicy fields = ( - 'name', 'description', 'version', 'mode', 'proposals', 'preshared_key', 'comments', 'tags', + 'name', 'description', 'version', 'mode', 'proposals', 'preshared_key', 'owner', 'comments', 'tags', ) -class IPSecProposalImportForm(NetBoxModelImportForm): +class IPSecProposalImportForm(PrimaryModelImportForm): encryption_algorithm = CSVChoiceField( label=_('Encryption algorithm'), choices=EncryptionAlgorithmChoices, @@ -206,11 +205,11 @@ class IPSecProposalImportForm(NetBoxModelImportForm): model = IPSecProposal fields = ( 'name', 'description', 'encryption_algorithm', 'authentication_algorithm', 'sa_lifetime_seconds', - 'sa_lifetime_data', 'comments', 'tags', + 'sa_lifetime_data', 'owner', 'comments', 'tags', ) -class IPSecPolicyImportForm(NetBoxModelImportForm): +class IPSecPolicyImportForm(PrimaryModelImportForm): pfs_group = CSVChoiceField( label=_('Diffie-Hellman group for Perfect Forward Secrecy'), choices=DHGroupChoices, @@ -225,11 +224,11 @@ class IPSecPolicyImportForm(NetBoxModelImportForm): class Meta: model = IPSecPolicy fields = ( - 'name', 'description', 'proposals', 'pfs_group', 'comments', 'tags', + 'name', 'description', 'proposals', 'pfs_group', 'owner', 'comments', 'tags', ) -class IPSecProfileImportForm(NetBoxModelImportForm): +class IPSecProfileImportForm(PrimaryModelImportForm): mode = CSVChoiceField( label=_('Mode'), choices=IPSecModeChoices, @@ -249,11 +248,11 @@ class IPSecProfileImportForm(NetBoxModelImportForm): class Meta: model = IPSecProfile fields = ( - 'name', 'mode', 'ike_policy', 'ipsec_policy', 'description', 'comments', 'tags', + 'name', 'mode', 'ike_policy', 'ipsec_policy', 'description', 'owner', 'comments', 'tags', ) -class L2VPNImportForm(NetBoxModelImportForm): +class L2VPNImportForm(PrimaryModelImportForm): tenant = CSVModelChoiceField( label=_('Tenant'), queryset=Tenant.objects.all(), @@ -273,8 +272,9 @@ class L2VPNImportForm(NetBoxModelImportForm): class Meta: model = L2VPN - fields = ('identifier', 'name', 'slug', 'tenant', 'type', 'description', - 'comments', 'tags') + fields = ( + 'identifier', 'name', 'slug', 'tenant', 'type', 'description', 'owner', 'comments', 'tags', + ) class L2VPNTerminationImportForm(NetBoxModelImportForm): diff --git a/netbox/vpn/forms/filtersets.py b/netbox/vpn/forms/filtersets.py index 4f814f709..4085d9ac5 100644 --- a/netbox/vpn/forms/filtersets.py +++ b/netbox/vpn/forms/filtersets.py @@ -4,7 +4,7 @@ from django.utils.translation import gettext as _ from dcim.models import Device, Region, Site from ipam.models import RouteTarget, VLAN -from netbox.forms import NetBoxModelFilterSetForm +from netbox.forms import NetBoxModelFilterSetForm, OrganizationalModelFilterSetForm, PrimaryModelFilterSetForm from tenancy.forms import ContactModelFilterForm, TenancyFilterForm from utilities.forms.fields import ( ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField, @@ -30,19 +30,19 @@ __all__ = ( ) -class TunnelGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): +class TunnelGroupFilterForm(ContactModelFilterForm, OrganizationalModelFilterSetForm): model = TunnelGroup fieldsets = ( - FieldSet('q', 'filter_id', 'tag'), + FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), ) tag = TagFilterField(model) -class TunnelFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm): +class TunnelFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm): model = Tunnel fieldsets = ( - FieldSet('q', 'filter_id', 'tag'), + FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('status', 'encapsulation', 'tunnel_id', name=_('Tunnel')), FieldSet('ipsec_profile_id', name=_('Security')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenancy')), @@ -94,10 +94,10 @@ class TunnelTerminationFilterForm(NetBoxModelFilterSetForm): tag = TagFilterField(model) -class IKEProposalFilterForm(NetBoxModelFilterSetForm): +class IKEProposalFilterForm(PrimaryModelFilterSetForm): model = IKEProposal fieldsets = ( - FieldSet('q', 'filter_id', 'tag'), + FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet( 'authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group', name=_('Parameters') ), @@ -125,10 +125,10 @@ class IKEProposalFilterForm(NetBoxModelFilterSetForm): tag = TagFilterField(model) -class IKEPolicyFilterForm(NetBoxModelFilterSetForm): +class IKEPolicyFilterForm(PrimaryModelFilterSetForm): model = IKEPolicy fieldsets = ( - FieldSet('q', 'filter_id', 'tag'), + FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('version', 'mode', 'proposal_id', name=_('Parameters')), ) version = forms.MultipleChoiceField( @@ -149,10 +149,10 @@ class IKEPolicyFilterForm(NetBoxModelFilterSetForm): tag = TagFilterField(model) -class IPSecProposalFilterForm(NetBoxModelFilterSetForm): +class IPSecProposalFilterForm(PrimaryModelFilterSetForm): model = IPSecProposal fieldsets = ( - FieldSet('q', 'filter_id', 'tag'), + FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('encryption_algorithm', 'authentication_algorithm', name=_('Parameters')), ) encryption_algorithm = forms.MultipleChoiceField( @@ -168,10 +168,10 @@ class IPSecProposalFilterForm(NetBoxModelFilterSetForm): tag = TagFilterField(model) -class IPSecPolicyFilterForm(NetBoxModelFilterSetForm): +class IPSecPolicyFilterForm(PrimaryModelFilterSetForm): model = IPSecPolicy fieldsets = ( - FieldSet('q', 'filter_id', 'tag'), + FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('proposal_id', 'pfs_group', name=_('Parameters')), ) proposal_id = DynamicModelMultipleChoiceField( @@ -187,10 +187,10 @@ class IPSecPolicyFilterForm(NetBoxModelFilterSetForm): tag = TagFilterField(model) -class IPSecProfileFilterForm(NetBoxModelFilterSetForm): +class IPSecProfileFilterForm(PrimaryModelFilterSetForm): model = IPSecProfile fieldsets = ( - FieldSet('q', 'filter_id', 'tag'), + FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('mode', 'ike_policy_id', 'ipsec_policy_id', name=_('Profile')), ) mode = forms.MultipleChoiceField( @@ -211,10 +211,10 @@ class IPSecProfileFilterForm(NetBoxModelFilterSetForm): tag = TagFilterField(model) -class L2VPNFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm): +class L2VPNFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm): model = L2VPN fieldsets = ( - FieldSet('q', 'filter_id', 'tag'), + FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('type', 'status', 'import_target_id', 'export_target_id', name=_('Attributes')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py index 1bf5b580c..ad9d73901 100644 --- a/netbox/vpn/forms/model_forms.py +++ b/netbox/vpn/forms/model_forms.py @@ -4,9 +4,9 @@ from django.utils.translation import gettext_lazy as _ from dcim.models import Device, Interface from ipam.models import IPAddress, RouteTarget, VLAN -from netbox.forms import NetBoxModelForm +from netbox.forms import NetBoxModelForm, OrganizationalModelForm, PrimaryModelForm from tenancy.forms import TenancyForm -from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField +from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField from utilities.forms.rendering import FieldSet, TabbedGroups from utilities.forms.utils import add_blank_choice, get_field_value from utilities.forms.widgets import HTMXSelect @@ -29,9 +29,7 @@ __all__ = ( ) -class TunnelGroupForm(NetBoxModelForm): - slug = SlugField() - +class TunnelGroupForm(OrganizationalModelForm): fieldsets = ( FieldSet('name', 'slug', 'description', 'tags', name=_('Tunnel Group')), ) @@ -39,11 +37,11 @@ class TunnelGroupForm(NetBoxModelForm): class Meta: model = TunnelGroup fields = [ - 'name', 'slug', 'description', 'tags', + 'name', 'slug', 'description', 'owner', 'tags', ] -class TunnelForm(TenancyForm, NetBoxModelForm): +class TunnelForm(TenancyForm, PrimaryModelForm): group = DynamicModelChoiceField( queryset=TunnelGroup.objects.all(), label=_('Tunnel Group'), @@ -55,7 +53,6 @@ class TunnelForm(TenancyForm, NetBoxModelForm): label=_('IPSec Profile'), required=False ) - comments = CommentField() fieldsets = ( FieldSet('name', 'status', 'group', 'encapsulation', 'description', 'tunnel_id', 'tags', name=_('Tunnel')), @@ -67,7 +64,7 @@ class TunnelForm(TenancyForm, NetBoxModelForm): model = Tunnel fields = [ 'name', 'status', 'group', 'encapsulation', 'description', 'tunnel_id', 'ipsec_profile', 'tenant_group', - 'tenant', 'comments', 'tags', + 'tenant', 'owner', 'comments', 'tags', ] @@ -293,7 +290,7 @@ class TunnelTerminationForm(NetBoxModelForm): self.instance.termination = self.cleaned_data.get('termination') -class IKEProposalForm(NetBoxModelForm): +class IKEProposalForm(PrimaryModelForm): fieldsets = ( FieldSet('name', 'description', 'tags', name=_('Proposal')), @@ -307,11 +304,11 @@ class IKEProposalForm(NetBoxModelForm): model = IKEProposal fields = [ 'name', 'description', 'authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group', - 'sa_lifetime', 'comments', 'tags', + 'sa_lifetime', 'owner', 'comments', 'tags', ] -class IKEPolicyForm(NetBoxModelForm): +class IKEPolicyForm(PrimaryModelForm): proposals = DynamicModelMultipleChoiceField( queryset=IKEProposal.objects.all(), label=_('Proposals'), @@ -326,11 +323,11 @@ class IKEPolicyForm(NetBoxModelForm): class Meta: model = IKEPolicy fields = [ - 'name', 'description', 'version', 'mode', 'proposals', 'preshared_key', 'comments', 'tags', + 'name', 'description', 'version', 'mode', 'proposals', 'preshared_key', 'owner', 'comments', 'tags', ] -class IPSecProposalForm(NetBoxModelForm): +class IPSecProposalForm(PrimaryModelForm): fieldsets = ( FieldSet('name', 'description', 'tags', name=_('Proposal')), @@ -344,11 +341,11 @@ class IPSecProposalForm(NetBoxModelForm): model = IPSecProposal fields = [ 'name', 'description', 'encryption_algorithm', 'authentication_algorithm', 'sa_lifetime_seconds', - 'sa_lifetime_data', 'comments', 'tags', + 'sa_lifetime_data', 'owner', 'comments', 'tags', ] -class IPSecPolicyForm(NetBoxModelForm): +class IPSecPolicyForm(PrimaryModelForm): proposals = DynamicModelMultipleChoiceField( queryset=IPSecProposal.objects.all(), label=_('Proposals'), @@ -363,11 +360,11 @@ class IPSecPolicyForm(NetBoxModelForm): class Meta: model = IPSecPolicy fields = [ - 'name', 'description', 'proposals', 'pfs_group', 'comments', 'tags', + 'name', 'description', 'proposals', 'pfs_group', 'owner', 'comments', 'tags', ] -class IPSecProfileForm(NetBoxModelForm): +class IPSecProfileForm(PrimaryModelForm): ike_policy = DynamicModelChoiceField( queryset=IKEPolicy.objects.all(), label=_('IKE policy') @@ -376,7 +373,6 @@ class IPSecProfileForm(NetBoxModelForm): queryset=IPSecPolicy.objects.all(), label=_('IPSec policy') ) - comments = CommentField() fieldsets = ( FieldSet('name', 'description', 'tags', name=_('Profile')), @@ -386,7 +382,7 @@ class IPSecProfileForm(NetBoxModelForm): class Meta: model = IPSecProfile fields = [ - 'name', 'description', 'mode', 'ike_policy', 'ipsec_policy', 'description', 'comments', 'tags', + 'name', 'description', 'mode', 'ike_policy', 'ipsec_policy', 'description', 'owner', 'comments', 'tags', ] @@ -394,7 +390,7 @@ class IPSecProfileForm(NetBoxModelForm): # L2VPN # -class L2VPNForm(TenancyForm, NetBoxModelForm): +class L2VPNForm(TenancyForm, PrimaryModelForm): slug = SlugField() import_targets = DynamicModelMultipleChoiceField( label=_('Import targets'), @@ -406,7 +402,6 @@ class L2VPNForm(TenancyForm, NetBoxModelForm): queryset=RouteTarget.objects.all(), required=False ) - comments = CommentField() fieldsets = ( FieldSet('name', 'slug', 'type', 'status', 'identifier', 'description', 'tags', name=_('L2VPN')), @@ -417,8 +412,8 @@ class L2VPNForm(TenancyForm, NetBoxModelForm): class Meta: model = L2VPN fields = ( - 'name', 'slug', 'type', 'status', 'identifier', 'import_targets', 'export_targets', 'tenant', - 'description', 'comments', 'tags' + 'name', 'slug', 'type', 'status', 'identifier', 'import_targets', 'export_targets', 'tenant', 'description', + 'owner', 'comments', 'tags' ) diff --git a/netbox/vpn/graphql/types.py b/netbox/vpn/graphql/types.py index e1b46f9c4..902880511 100644 --- a/netbox/vpn/graphql/types.py +++ b/netbox/vpn/graphql/types.py @@ -4,7 +4,7 @@ import strawberry import strawberry_django from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin -from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType +from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType, PrimaryObjectType from vpn import models from .filters import * @@ -58,7 +58,7 @@ class TunnelTerminationType(CustomFieldsMixin, TagsMixin, ObjectType): filters=TunnelFilter, pagination=True ) -class TunnelType(ContactsMixin, NetBoxObjectType): +class TunnelType(ContactsMixin, PrimaryObjectType): group: Annotated["TunnelGroupType", strawberry.lazy('vpn.graphql.types')] | None ipsec_profile: Annotated["IPSecProfileType", strawberry.lazy('vpn.graphql.types')] | None tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None @@ -72,8 +72,7 @@ class TunnelType(ContactsMixin, NetBoxObjectType): filters=IKEProposalFilter, pagination=True ) -class IKEProposalType(OrganizationalObjectType): - +class IKEProposalType(PrimaryObjectType): ike_policies: List[Annotated["IKEPolicyType", strawberry.lazy('vpn.graphql.types')]] @@ -83,8 +82,7 @@ class IKEProposalType(OrganizationalObjectType): filters=IKEPolicyFilter, pagination=True ) -class IKEPolicyType(OrganizationalObjectType): - +class IKEPolicyType(PrimaryObjectType): proposals: List[Annotated["IKEProposalType", strawberry.lazy('vpn.graphql.types')]] ipsec_profiles: List[Annotated["IPSecProfileType", strawberry.lazy('vpn.graphql.types')]] @@ -95,8 +93,7 @@ class IKEPolicyType(OrganizationalObjectType): filters=IPSecProposalFilter, pagination=True ) -class IPSecProposalType(OrganizationalObjectType): - +class IPSecProposalType(PrimaryObjectType): ipsec_policies: List[Annotated["IPSecPolicyType", strawberry.lazy('vpn.graphql.types')]] @@ -106,8 +103,7 @@ class IPSecProposalType(OrganizationalObjectType): filters=IPSecPolicyFilter, pagination=True ) -class IPSecPolicyType(OrganizationalObjectType): - +class IPSecPolicyType(PrimaryObjectType): proposals: List[Annotated["IPSecProposalType", strawberry.lazy('vpn.graphql.types')]] ipsec_profiles: List[Annotated["IPSecProfileType", strawberry.lazy('vpn.graphql.types')]] @@ -118,7 +114,7 @@ class IPSecPolicyType(OrganizationalObjectType): filters=IPSecProfileFilter, pagination=True ) -class IPSecProfileType(OrganizationalObjectType): +class IPSecProfileType(PrimaryObjectType): ike_policy: Annotated["IKEPolicyType", strawberry.lazy('vpn.graphql.types')] ipsec_policy: Annotated["IPSecPolicyType", strawberry.lazy('vpn.graphql.types')] @@ -131,7 +127,7 @@ class IPSecProfileType(OrganizationalObjectType): filters=L2VPNFilter, pagination=True ) -class L2VPNType(ContactsMixin, NetBoxObjectType): +class L2VPNType(ContactsMixin, PrimaryObjectType): tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None export_targets: List[Annotated["RouteTargetType", strawberry.lazy('ipam.graphql.types')]] diff --git a/netbox/vpn/migrations/0010_owner.py b/netbox/vpn/migrations/0010_owner.py new file mode 100644 index 000000000..19749f213 --- /dev/null +++ b/netbox/vpn/migrations/0010_owner.py @@ -0,0 +1,68 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('users', '0015_owner'), + ('vpn', '0009_remove_redundant_indexes'), + ] + + operations = [ + migrations.AddField( + model_name='ikepolicy', + name='owner', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + ), + ), + migrations.AddField( + model_name='ikeproposal', + name='owner', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + ), + ), + migrations.AddField( + model_name='ipsecpolicy', + name='owner', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + ), + ), + migrations.AddField( + model_name='ipsecprofile', + name='owner', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + ), + ), + migrations.AddField( + model_name='ipsecproposal', + name='owner', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + ), + ), + migrations.AddField( + model_name='l2vpn', + name='owner', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + ), + ), + migrations.AddField( + model_name='tunnel', + name='owner', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + ), + ), + migrations.AddField( + model_name='tunnelgroup', + name='owner', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + ), + ), + ] diff --git a/netbox/vpn/tables/crypto.py b/netbox/vpn/tables/crypto.py index 474062b39..49a402559 100644 --- a/netbox/vpn/tables/crypto.py +++ b/netbox/vpn/tables/crypto.py @@ -1,7 +1,7 @@ import django_tables2 as tables from django.utils.translation import gettext_lazy as _ -from netbox.tables import NetBoxTable, columns +from netbox.tables import PrimaryModelTable, columns from vpn.models import * __all__ = ( @@ -13,7 +13,7 @@ __all__ = ( ) -class IKEProposalTable(NetBoxTable): +class IKEProposalTable(PrimaryModelTable): name = tables.Column( verbose_name=_('Name'), linkify=True @@ -33,14 +33,11 @@ class IKEProposalTable(NetBoxTable): sa_lifetime = tables.Column( verbose_name=_('SA Lifetime') ) - comments = columns.MarkdownColumn( - verbose_name=_('Comments'), - ) tags = columns.TagColumn( url_name='vpn:ikeproposal_list' ) - class Meta(NetBoxTable.Meta): + class Meta(PrimaryModelTable.Meta): model = IKEProposal fields = ( 'pk', 'id', 'name', 'authentication_method', 'encryption_algorithm', 'authentication_algorithm', @@ -52,7 +49,7 @@ class IKEProposalTable(NetBoxTable): ) -class IKEPolicyTable(NetBoxTable): +class IKEPolicyTable(PrimaryModelTable): name = tables.Column( verbose_name=_('Name'), linkify=True @@ -70,14 +67,11 @@ class IKEPolicyTable(NetBoxTable): preshared_key = tables.Column( verbose_name=_('Pre-shared Key') ) - comments = columns.MarkdownColumn( - verbose_name=_('Comments'), - ) tags = columns.TagColumn( url_name='vpn:ikepolicy_list' ) - class Meta(NetBoxTable.Meta): + class Meta(PrimaryModelTable.Meta): model = IKEPolicy fields = ( 'pk', 'id', 'name', 'version', 'mode', 'proposals', 'preshared_key', 'description', 'comments', 'tags', @@ -88,7 +82,7 @@ class IKEPolicyTable(NetBoxTable): ) -class IPSecProposalTable(NetBoxTable): +class IPSecProposalTable(PrimaryModelTable): name = tables.Column( verbose_name=_('Name'), linkify=True @@ -105,14 +99,11 @@ class IPSecProposalTable(NetBoxTable): sa_lifetime_data = tables.Column( verbose_name=_('SA Lifetime (KB)') ) - comments = columns.MarkdownColumn( - verbose_name=_('Comments'), - ) tags = columns.TagColumn( url_name='vpn:ipsecproposal_list' ) - class Meta(NetBoxTable.Meta): + class Meta(PrimaryModelTable.Meta): model = IPSecProposal fields = ( 'pk', 'id', 'name', 'encryption_algorithm', 'authentication_algorithm', 'sa_lifetime_seconds', @@ -124,7 +115,7 @@ class IPSecProposalTable(NetBoxTable): ) -class IPSecPolicyTable(NetBoxTable): +class IPSecPolicyTable(PrimaryModelTable): name = tables.Column( verbose_name=_('Name'), linkify=True @@ -136,14 +127,11 @@ class IPSecPolicyTable(NetBoxTable): pfs_group = tables.Column( verbose_name=_('PFS Group') ) - comments = columns.MarkdownColumn( - verbose_name=_('Comments'), - ) tags = columns.TagColumn( url_name='vpn:ipsecpolicy_list' ) - class Meta(NetBoxTable.Meta): + class Meta(PrimaryModelTable.Meta): model = IPSecPolicy fields = ( 'pk', 'id', 'name', 'proposals', 'pfs_group', 'description', 'comments', 'tags', 'created', 'last_updated', @@ -153,7 +141,7 @@ class IPSecPolicyTable(NetBoxTable): ) -class IPSecProfileTable(NetBoxTable): +class IPSecProfileTable(PrimaryModelTable): name = tables.Column( verbose_name=_('Name'), linkify=True @@ -169,14 +157,11 @@ class IPSecProfileTable(NetBoxTable): linkify=True, verbose_name=_('IPSec Policy') ) - comments = columns.MarkdownColumn( - verbose_name=_('Comments'), - ) tags = columns.TagColumn( url_name='vpn:ipsecprofile_list' ) - class Meta(NetBoxTable.Meta): + class Meta(PrimaryModelTable.Meta): model = IPSecProfile fields = ( 'pk', 'id', 'name', 'mode', 'ike_policy', 'ipsec_policy', 'description', 'comments', 'tags', 'created', diff --git a/netbox/vpn/tables/l2vpn.py b/netbox/vpn/tables/l2vpn.py index 95586461e..72f4201c4 100644 --- a/netbox/vpn/tables/l2vpn.py +++ b/netbox/vpn/tables/l2vpn.py @@ -1,7 +1,7 @@ import django_tables2 as tables from django.utils.translation import gettext_lazy as _ -from netbox.tables import NetBoxTable, columns +from netbox.tables import NetBoxTable, PrimaryModelTable, columns from tenancy.tables import TenancyColumnsMixin from vpn.models import L2VPN, L2VPNTermination @@ -17,7 +17,7 @@ L2VPN_TARGETS = """ """ -class L2VPNTable(TenancyColumnsMixin, NetBoxTable): +class L2VPNTable(TenancyColumnsMixin, PrimaryModelTable): pk = columns.ToggleColumn() name = tables.Column( verbose_name=_('Name'), @@ -36,14 +36,11 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable): template_code=L2VPN_TARGETS, orderable=False ) - comments = columns.MarkdownColumn( - verbose_name=_('Comments'), - ) tags = columns.TagColumn( url_name='vpn:l2vpn_list' ) - class Meta(NetBoxTable.Meta): + class Meta(PrimaryModelTable.Meta): model = L2VPN fields = ( 'pk', 'name', 'slug', 'status', 'identifier', 'type', 'import_targets', 'export_targets', 'tenant', diff --git a/netbox/vpn/tables/tunnels.py b/netbox/vpn/tables/tunnels.py index fc8dec5e4..4cbe440f6 100644 --- a/netbox/vpn/tables/tunnels.py +++ b/netbox/vpn/tables/tunnels.py @@ -2,7 +2,7 @@ import django_tables2 as tables from django.utils.translation import gettext_lazy as _ from django_tables2.utils import Accessor -from netbox.tables import NetBoxTable, columns +from netbox.tables import NetBoxTable, OrganizationalModelTable, PrimaryModelTable, columns from tenancy.tables import TenancyColumnsMixin from vpn.models import * @@ -13,7 +13,7 @@ __all__ = ( ) -class TunnelGroupTable(NetBoxTable): +class TunnelGroupTable(OrganizationalModelTable): name = tables.Column( verbose_name=_('Name'), linkify=True @@ -27,7 +27,7 @@ class TunnelGroupTable(NetBoxTable): url_name='vpn:tunnelgroup_list' ) - class Meta(NetBoxTable.Meta): + class Meta(OrganizationalModelTable.Meta): model = TunnelGroup fields = ( 'pk', 'id', 'name', 'tunnel_count', 'description', 'slug', 'tags', 'actions', 'created', 'last_updated', @@ -35,7 +35,7 @@ class TunnelGroupTable(NetBoxTable): default_columns = ('pk', 'name', 'tunnel_count', 'description') -class TunnelTable(TenancyColumnsMixin, NetBoxTable): +class TunnelTable(TenancyColumnsMixin, PrimaryModelTable): name = tables.Column( verbose_name=_('Name'), linkify=True @@ -57,14 +57,11 @@ class TunnelTable(TenancyColumnsMixin, NetBoxTable): url_params={'tunnel_id': 'pk'}, verbose_name=_('Terminations') ) - comments = columns.MarkdownColumn( - verbose_name=_('Comments'), - ) tags = columns.TagColumn( url_name='vpn:tunnel_list' ) - class Meta(NetBoxTable.Meta): + class Meta(PrimaryModelTable.Meta): model = Tunnel fields = ( 'pk', 'id', 'name', 'group', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'tenant_group', diff --git a/netbox/wireless/api/serializers_/wirelesslans.py b/netbox/wireless/api/serializers_/wirelesslans.py index 97d57f9f5..7403fe860 100644 --- a/netbox/wireless/api/serializers_/wirelesslans.py +++ b/netbox/wireless/api/serializers_/wirelesslans.py @@ -1,11 +1,11 @@ +from django.contrib.contenttypes.models import ContentType +from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from dcim.constants import LOCATION_SCOPE_TYPES -from django.contrib.contenttypes.models import ContentType -from drf_spectacular.utils import extend_schema_field from ipam.api.serializers_.vlans import VLANSerializer from netbox.api.fields import ChoiceField, ContentTypeField -from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer +from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer from tenancy.api.serializers_.tenants import TenantSerializer from utilities.api import get_serializer_for_model from wireless.choices import * @@ -26,12 +26,12 @@ class WirelessLANGroupSerializer(NestedGroupModelSerializer): model = WirelessLANGroup fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'wirelesslan_count', 'comments', '_depth', + 'created', 'last_updated', 'wirelesslan_count', 'owner', 'comments', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'wirelesslan_count', '_depth') -class WirelessLANSerializer(NetBoxModelSerializer): +class WirelessLANSerializer(PrimaryModelSerializer): group = WirelessLANGroupSerializer(nested=True, required=False, allow_null=True) status = ChoiceField(choices=WirelessLANStatusChoices, required=False, allow_blank=True) vlan = VLANSerializer(nested=True, required=False, allow_null=True) @@ -53,8 +53,8 @@ class WirelessLANSerializer(NetBoxModelSerializer): model = WirelessLAN fields = [ 'id', 'url', 'display_url', 'display', 'ssid', 'description', 'group', 'status', 'vlan', 'scope_type', - 'scope_id', 'scope', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags', - 'custom_fields', 'created', 'last_updated', + 'scope_id', 'scope', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', 'owner', 'comments', + 'tags', 'custom_fields', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'ssid', 'description') diff --git a/netbox/wireless/api/serializers_/wirelesslinks.py b/netbox/wireless/api/serializers_/wirelesslinks.py index 62f9988f1..7b010d298 100644 --- a/netbox/wireless/api/serializers_/wirelesslinks.py +++ b/netbox/wireless/api/serializers_/wirelesslinks.py @@ -1,7 +1,7 @@ from dcim.api.serializers_.device_components import InterfaceSerializer from dcim.choices import LinkStatusChoices from netbox.api.fields import ChoiceField -from netbox.api.serializers import NetBoxModelSerializer +from netbox.api.serializers import PrimaryModelSerializer from netbox.choices import * from tenancy.api.serializers_.tenants import TenantSerializer from wireless.choices import * @@ -12,7 +12,7 @@ __all__ = ( ) -class WirelessLinkSerializer(NetBoxModelSerializer): +class WirelessLinkSerializer(PrimaryModelSerializer): status = ChoiceField(choices=LinkStatusChoices, required=False) interface_a = InterfaceSerializer(nested=True) interface_b = InterfaceSerializer(nested=True) @@ -25,7 +25,7 @@ class WirelessLinkSerializer(NetBoxModelSerializer): model = WirelessLink fields = [ 'id', 'url', 'display_url', 'display', 'interface_a', 'interface_b', 'ssid', 'status', 'tenant', - 'auth_type', 'auth_cipher', 'auth_psk', 'distance', 'distance_unit', 'description', - 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'auth_type', 'auth_cipher', 'auth_psk', 'distance', 'distance_unit', 'description', 'owner', 'comments', + 'tags', 'custom_fields', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'ssid', 'description') diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index bd96865ad..afd963a5a 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -5,7 +5,7 @@ from dcim.choices import LinkStatusChoices from dcim.base_filtersets import ScopedFilterSet from dcim.models import Interface from ipam.models import VLAN -from netbox.filtersets import NestedGroupModelFilterSet, NetBoxModelFilterSet +from netbox.filtersets import NestedGroupModelFilterSet, PrimaryModelFilterSet from tenancy.filtersets import TenancyFilterSet from utilities.filters import TreeNodeMultipleChoiceFilter from .choices import * @@ -44,7 +44,7 @@ class WirelessLANGroupFilterSet(NestedGroupModelFilterSet): fields = ('id', 'name', 'slug', 'description') -class WirelessLANFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet): +class WirelessLANFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilterSet): group_id = TreeNodeMultipleChoiceFilter( queryset=WirelessLANGroup.objects.all(), field_name='group', @@ -87,7 +87,7 @@ class WirelessLANFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterS return queryset.filter(qs_filter) -class WirelessLinkFilterSet(NetBoxModelFilterSet, TenancyFilterSet): +class WirelessLinkFilterSet(PrimaryModelFilterSet, TenancyFilterSet): interface_a_id = django_filters.ModelMultipleChoiceFilter( queryset=Interface.objects.all() ) diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index 1a75512e1..98645dfb8 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -5,10 +5,10 @@ from dcim.choices import LinkStatusChoices from dcim.forms.mixins import ScopedBulkEditForm from ipam.models import VLAN from netbox.choices import * -from netbox.forms import NetBoxModelBulkEditForm +from netbox.forms import NestedGroupModelBulkEditForm, PrimaryModelBulkEditForm from tenancy.models import Tenant from utilities.forms import add_blank_choice -from utilities.forms.fields import CommentField, DynamicModelChoiceField +from utilities.forms.fields import DynamicModelChoiceField from utilities.forms.rendering import FieldSet from wireless.choices import * from wireless.constants import SSID_MAX_LENGTH @@ -21,18 +21,12 @@ __all__ = ( ) -class WirelessLANGroupBulkEditForm(NetBoxModelBulkEditForm): +class WirelessLANGroupBulkEditForm(NestedGroupModelBulkEditForm): parent = DynamicModelChoiceField( label=_('Parent'), queryset=WirelessLANGroup.objects.all(), required=False ) - description = forms.CharField( - label=_('Description'), - max_length=200, - required=False - ) - comments = CommentField() model = WirelessLANGroup fieldsets = ( @@ -41,7 +35,7 @@ class WirelessLANGroupBulkEditForm(NetBoxModelBulkEditForm): nullable_fields = ('parent', 'description', 'comments') -class WirelessLANBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm): +class WirelessLANBulkEditForm(ScopedBulkEditForm, PrimaryModelBulkEditForm): status = forms.ChoiceField( label=_('Status'), choices=add_blank_choice(WirelessLANStatusChoices), @@ -81,12 +75,6 @@ class WirelessLANBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm): required=False, label=_('Pre-shared key') ) - description = forms.CharField( - label=_('Description'), - max_length=200, - required=False - ) - comments = CommentField() model = WirelessLAN fieldsets = ( @@ -99,7 +87,7 @@ class WirelessLANBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm): ) -class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): +class WirelessLinkBulkEditForm(PrimaryModelBulkEditForm): ssid = forms.CharField( max_length=SSID_MAX_LENGTH, required=False, @@ -140,12 +128,6 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): required=False, initial='' ) - description = forms.CharField( - label=_('Description'), - max_length=200, - required=False - ) - comments = CommentField() model = WirelessLink fieldsets = ( diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index 29395f814..64f8c8563 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -5,9 +5,9 @@ from dcim.forms.mixins import ScopedImportForm from dcim.models import Device, Interface, Site from ipam.models import VLAN from netbox.choices import * -from netbox.forms import NetBoxModelImportForm +from netbox.forms import NestedGroupModelImportForm, PrimaryModelImportForm from tenancy.models import Tenant -from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField +from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField from wireless.choices import * from wireless.models import * @@ -18,7 +18,7 @@ __all__ = ( ) -class WirelessLANGroupImportForm(NetBoxModelImportForm): +class WirelessLANGroupImportForm(NestedGroupModelImportForm): parent = CSVModelChoiceField( label=_('Parent'), queryset=WirelessLANGroup.objects.all(), @@ -26,14 +26,13 @@ class WirelessLANGroupImportForm(NetBoxModelImportForm): to_field_name='name', help_text=_('Parent group') ) - slug = SlugField() class Meta: model = WirelessLANGroup - fields = ('name', 'slug', 'parent', 'description', 'tags', 'comments') + fields = ('name', 'slug', 'parent', 'description', 'owner', 'comments', 'tags') -class WirelessLANImportForm(ScopedImportForm, NetBoxModelImportForm): +class WirelessLANImportForm(ScopedImportForm, PrimaryModelImportForm): group = CSVModelChoiceField( label=_('Group'), queryset=WirelessLANGroup.objects.all(), @@ -77,14 +76,14 @@ class WirelessLANImportForm(ScopedImportForm, NetBoxModelImportForm): model = WirelessLAN fields = ( 'ssid', 'group', 'status', 'vlan', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'scope_type', - 'scope_id', 'description', 'comments', 'tags', + 'scope_id', 'description', 'owner', 'comments', 'tags', ) labels = { 'scope_id': _('Scope ID'), } -class WirelessLinkImportForm(NetBoxModelImportForm): +class WirelessLinkImportForm(PrimaryModelImportForm): # Termination A site_a = CSVModelChoiceField( label=_('Site A'), @@ -163,7 +162,8 @@ class WirelessLinkImportForm(NetBoxModelImportForm): model = WirelessLink fields = ( 'site_a', 'device_a', 'interface_a', 'site_b', 'device_b', 'interface_b', 'status', 'ssid', 'tenant', - 'auth_type', 'auth_cipher', 'auth_psk', 'distance', 'distance_unit', 'description', 'comments', 'tags', + 'auth_type', 'auth_cipher', 'auth_psk', 'distance', 'distance_unit', 'description', 'owner', 'comments', + 'tags', ) def __init__(self, data=None, *args, **kwargs): diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index f62a3be06..171a7d8b6 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -4,7 +4,7 @@ from django.utils.translation import gettext_lazy as _ from dcim.choices import LinkStatusChoices from dcim.models import Location, Region, Site, SiteGroup from netbox.choices import * -from netbox.forms import NetBoxModelFilterSetForm +from netbox.forms import NestedGroupModelFilterSetForm, PrimaryModelFilterSetForm from tenancy.forms import TenancyFilterForm from utilities.forms import add_blank_choice from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField @@ -19,8 +19,12 @@ __all__ = ( ) -class WirelessLANGroupFilterForm(NetBoxModelFilterSetForm): +class WirelessLANGroupFilterForm(NestedGroupModelFilterSetForm): model = WirelessLANGroup + fieldsets = ( + FieldSet('q', 'filter_id', 'tag', 'owner_id'), + FieldSet('parent_id', name=_('Wireless LAN group')), + ) parent_id = DynamicModelMultipleChoiceField( queryset=WirelessLANGroup.objects.all(), required=False, @@ -29,10 +33,10 @@ class WirelessLANGroupFilterForm(NetBoxModelFilterSetForm): tag = TagFilterField(model) -class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): +class WirelessLANFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm): model = WirelessLAN fieldsets = ( - FieldSet('q', 'filter_id', 'tag'), + FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('ssid', 'group_id', 'status', name=_('Attributes')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), @@ -95,10 +99,10 @@ class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): tag = TagFilterField(model) -class WirelessLinkFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): +class WirelessLinkFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm): model = WirelessLink fieldsets = ( - FieldSet('q', 'filter_id', 'tag'), + FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('ssid', 'status', 'distance', 'distance_unit', name=_('Attributes')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')), diff --git a/netbox/wireless/forms/model_forms.py b/netbox/wireless/forms/model_forms.py index 08f418e3c..0cd107ba6 100644 --- a/netbox/wireless/forms/model_forms.py +++ b/netbox/wireless/forms/model_forms.py @@ -1,12 +1,12 @@ from django.forms import PasswordInput from django.utils.translation import gettext_lazy as _ -from dcim.models import Device, Interface, Location, Site from dcim.forms.mixins import ScopedForm +from dcim.models import Device, Interface, Location, Site from ipam.models import VLAN -from netbox.forms import NetBoxModelForm +from netbox.forms import NestedGroupModelForm, PrimaryModelForm from tenancy.forms import TenancyForm -from utilities.forms.fields import CommentField, DynamicModelChoiceField, SlugField +from utilities.forms.fields import DynamicModelChoiceField from utilities.forms.mixins import DistanceValidationMixin from utilities.forms.rendering import FieldSet, InlineFields from wireless.models import * @@ -18,14 +18,12 @@ __all__ = ( ) -class WirelessLANGroupForm(NetBoxModelForm): +class WirelessLANGroupForm(NestedGroupModelForm): parent = DynamicModelChoiceField( label=_('Parent'), queryset=WirelessLANGroup.objects.all(), required=False ) - slug = SlugField() - comments = CommentField() fieldsets = ( FieldSet('parent', 'name', 'slug', 'description', 'tags', name=_('Wireless LAN Group')), @@ -34,11 +32,11 @@ class WirelessLANGroupForm(NetBoxModelForm): class Meta: model = WirelessLANGroup fields = [ - 'parent', 'name', 'slug', 'description', 'tags', 'comments', + 'parent', 'name', 'slug', 'description', 'owner', 'comments', 'tags', ] -class WirelessLANForm(ScopedForm, TenancyForm, NetBoxModelForm): +class WirelessLANForm(ScopedForm, TenancyForm, PrimaryModelForm): group = DynamicModelChoiceField( label=_('Group'), queryset=WirelessLANGroup.objects.all(), @@ -51,7 +49,6 @@ class WirelessLANForm(ScopedForm, TenancyForm, NetBoxModelForm): selector=True, label=_('VLAN') ) - comments = CommentField() fieldsets = ( FieldSet('ssid', 'group', 'vlan', 'status', 'description', 'tags', name=_('Wireless LAN')), @@ -64,7 +61,7 @@ class WirelessLANForm(ScopedForm, TenancyForm, NetBoxModelForm): model = WirelessLAN fields = [ 'ssid', 'group', 'status', 'vlan', 'tenant_group', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', - 'scope_type', 'description', 'comments', 'tags', + 'scope_type', 'description', 'owner', 'comments', 'tags', ] widgets = { 'auth_psk': PasswordInput( @@ -74,7 +71,7 @@ class WirelessLANForm(ScopedForm, TenancyForm, NetBoxModelForm): } -class WirelessLinkForm(DistanceValidationMixin, TenancyForm, NetBoxModelForm): +class WirelessLinkForm(DistanceValidationMixin, TenancyForm, PrimaryModelForm): site_a = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, @@ -159,7 +156,6 @@ class WirelessLinkForm(DistanceValidationMixin, TenancyForm, NetBoxModelForm): }, label=_('Interface') ) - comments = CommentField() fieldsets = ( FieldSet('site_a', 'location_a', 'device_a', 'interface_a', name=_('Side A')), @@ -181,7 +177,7 @@ class WirelessLinkForm(DistanceValidationMixin, TenancyForm, NetBoxModelForm): fields = [ 'site_a', 'location_a', 'device_a', 'interface_a', 'site_b', 'location_b', 'device_b', 'interface_b', 'status', 'ssid', 'tenant_group', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', - 'distance', 'distance_unit', 'description', 'comments', 'tags', + 'distance', 'distance_unit', 'description', 'owner', 'comments', 'tags', ] widgets = { 'auth_psk': PasswordInput( diff --git a/netbox/wireless/graphql/types.py b/netbox/wireless/graphql/types.py index eeca6a82b..124056b91 100644 --- a/netbox/wireless/graphql/types.py +++ b/netbox/wireless/graphql/types.py @@ -3,7 +3,7 @@ from typing import Annotated, List, TYPE_CHECKING, Union import strawberry import strawberry_django -from netbox.graphql.types import OrganizationalObjectType, NetBoxObjectType +from netbox.graphql.types import NestedGroupObjectType, PrimaryObjectType from wireless import models from .filters import * @@ -25,7 +25,7 @@ __all__ = ( filters=WirelessLANGroupFilter, pagination=True ) -class WirelessLANGroupType(OrganizationalObjectType): +class WirelessLANGroupType(NestedGroupObjectType): parent: Annotated["WirelessLANGroupType", strawberry.lazy('wireless.graphql.types')] | None wireless_lans: List[Annotated["WirelessLANType", strawberry.lazy('wireless.graphql.types')]] @@ -38,7 +38,7 @@ class WirelessLANGroupType(OrganizationalObjectType): filters=WirelessLANFilter, pagination=True ) -class WirelessLANType(NetBoxObjectType): +class WirelessLANType(PrimaryObjectType): group: Annotated["WirelessLANGroupType", strawberry.lazy('wireless.graphql.types')] | None vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None @@ -61,7 +61,7 @@ class WirelessLANType(NetBoxObjectType): filters=WirelessLinkFilter, pagination=True ) -class WirelessLinkType(NetBoxObjectType): +class WirelessLinkType(PrimaryObjectType): interface_a: Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')] interface_b: Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')] tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None diff --git a/netbox/wireless/migrations/0016_owner.py b/netbox/wireless/migrations/0016_owner.py new file mode 100644 index 000000000..fa753ffc3 --- /dev/null +++ b/netbox/wireless/migrations/0016_owner.py @@ -0,0 +1,33 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('users', '0015_owner'), + ('wireless', '0015_extend_wireless_link_abs_distance_upper_limit'), + ] + + operations = [ + migrations.AddField( + model_name='wirelesslan', + name='owner', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + ), + ), + migrations.AddField( + model_name='wirelesslangroup', + name='owner', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + ), + ), + migrations.AddField( + model_name='wirelesslink', + name='owner', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + ), + ), + ] diff --git a/netbox/wireless/tables/wirelesslan.py b/netbox/wireless/tables/wirelesslan.py index 24ad64345..bc4926e82 100644 --- a/netbox/wireless/tables/wirelesslan.py +++ b/netbox/wireless/tables/wirelesslan.py @@ -1,8 +1,8 @@ -from django.utils.translation import gettext_lazy as _ import django_tables2 as tables +from django.utils.translation import gettext_lazy as _ from dcim.models import Interface -from netbox.tables import NetBoxTable, columns +from netbox.tables import NestedGroupModelTable, NetBoxTable, PrimaryModelTable, columns from tenancy.tables import TenancyColumnsMixin from wireless.models import * @@ -13,28 +13,17 @@ __all__ = ( ) -class WirelessLANGroupTable(NetBoxTable): - name = columns.MPTTColumn( - verbose_name=_('Name'), - linkify=True - ) - parent = tables.Column( - verbose_name=_('Parent'), - linkify=True, - ) +class WirelessLANGroupTable(NestedGroupModelTable): wirelesslan_count = columns.LinkedCountColumn( viewname='wireless:wirelesslan_list', url_params={'group_id': 'pk'}, verbose_name=_('Wireless LANs') ) - comments = columns.MarkdownColumn( - verbose_name=_('Comments'), - ) tags = columns.TagColumn( url_name='wireless:wirelesslangroup_list' ) - class Meta(NetBoxTable.Meta): + class Meta(NestedGroupModelTable.Meta): model = WirelessLANGroup fields = ( 'pk', 'name', 'parent', 'slug', 'description', 'comments', 'tags', 'wirelesslan_count', 'created', @@ -43,7 +32,7 @@ class WirelessLANGroupTable(NetBoxTable): default_columns = ('pk', 'name', 'wirelesslan_count', 'description') -class WirelessLANTable(TenancyColumnsMixin, NetBoxTable): +class WirelessLANTable(TenancyColumnsMixin, PrimaryModelTable): ssid = tables.Column( verbose_name=_('SSID'), linkify=True @@ -66,14 +55,11 @@ class WirelessLANTable(TenancyColumnsMixin, NetBoxTable): interface_count = tables.Column( verbose_name=_('Interfaces') ) - comments = columns.MarkdownColumn( - verbose_name=_('Comments'), - ) tags = columns.TagColumn( url_name='wireless:wirelesslan_list' ) - class Meta(NetBoxTable.Meta): + class Meta(PrimaryModelTable.Meta): model = WirelessLAN fields = ( 'pk', 'ssid', 'group', 'status', 'tenant', 'tenant_group', 'vlan', 'interface_count', 'auth_type', diff --git a/netbox/wireless/tables/wirelesslink.py b/netbox/wireless/tables/wirelesslink.py index dc8fb66db..465ab08db 100644 --- a/netbox/wireless/tables/wirelesslink.py +++ b/netbox/wireless/tables/wirelesslink.py @@ -1,7 +1,7 @@ -from django.utils.translation import gettext_lazy as _ import django_tables2 as tables +from django.utils.translation import gettext_lazy as _ -from netbox.tables import NetBoxTable, columns +from netbox.tables import PrimaryModelTable, columns from tenancy.tables import TenancyColumnsMixin from wireless.models import * @@ -10,7 +10,7 @@ __all__ = ( ) -class WirelessLinkTable(TenancyColumnsMixin, NetBoxTable): +class WirelessLinkTable(TenancyColumnsMixin, PrimaryModelTable): id = tables.Column( linkify=True, verbose_name=_('ID') @@ -41,7 +41,7 @@ class WirelessLinkTable(TenancyColumnsMixin, NetBoxTable): url_name='wireless:wirelesslink_list' ) - class Meta(NetBoxTable.Meta): + class Meta(PrimaryModelTable.Meta): model = WirelessLink fields = ( 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'tenant', From 9b064e678d74b36332426fe61d5528ec18c2d4ec Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 24 Oct 2025 14:46:17 -0700 Subject: [PATCH 037/125] 20683 remove swap Circuit Terminations --- netbox/circuits/urls.py | 5 -- netbox/circuits/views.py | 81 ------------------- .../circuits/inc/circuit_termination.html | 3 - 3 files changed, 89 deletions(-) diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 594570638..c5f232852 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -18,11 +18,6 @@ urlpatterns = [ path('circuit-types//', include(get_model_urls('circuits', 'circuittype'))), path('circuits/', include(get_model_urls('circuits', 'circuit', detail=False))), - path( - 'circuits//terminations/swap/', - views.CircuitSwapTerminations.as_view(), - name='circuit_terminations_swap' - ), path('circuits//', include(get_model_urls('circuits', 'circuit'))), path('circuit-terminations/', include(get_model_urls('circuits', 'circuittermination', detail=False))), diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 89ec03831..ccd9ef6bf 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -1,13 +1,8 @@ -from django.contrib import messages -from django.db import router, transaction -from django.shortcuts import get_object_or_404, redirect, render -from django.utils.translation import gettext_lazy as _ from dcim.views import PathTraceView from ipam.models import ASN from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport from netbox.views import generic -from utilities.forms import ConfirmationForm from utilities.query import count_related from utilities.views import GetRelatedModelsMixin, register_model_view from . import filtersets, forms, tables @@ -373,82 +368,6 @@ class CircuitBulkDeleteView(generic.BulkDeleteView): table = tables.CircuitTable -class CircuitSwapTerminations(generic.ObjectEditView): - """ - Swap the A and Z terminations of a circuit. - """ - queryset = Circuit.objects.all() - - def get(self, request, pk): - circuit = get_object_or_404(self.queryset, pk=pk) - form = ConfirmationForm() - - # Circuit must have at least one termination to swap - if not circuit.termination_a and not circuit.termination_z: - messages.error(request, _( - "No terminations have been defined for circuit {circuit}." - ).format(circuit=circuit)) - return redirect('circuits:circuit', pk=circuit.pk) - - return render(request, 'circuits/circuit_terminations_swap.html', { - 'circuit': circuit, - 'termination_a': circuit.termination_a, - 'termination_z': circuit.termination_z, - 'form': form, - 'panel_class': 'light', - 'button_class': 'primary', - 'return_url': circuit.get_absolute_url(), - }) - - def post(self, request, pk): - circuit = get_object_or_404(self.queryset, pk=pk) - form = ConfirmationForm(request.POST) - - if form.is_valid(): - - termination_a = CircuitTermination.objects.filter(pk=circuit.termination_a_id).first() - termination_z = CircuitTermination.objects.filter(pk=circuit.termination_z_id).first() - - if termination_a and termination_z: - # Use a placeholder to avoid an IntegrityError on the (circuit, term_side) unique constraint - with transaction.atomic(using=router.db_for_write(CircuitTermination)): - termination_a.term_side = '_' - termination_a.save() - termination_z.term_side = 'A' - termination_z.save() - termination_a.term_side = 'Z' - termination_a.save() - circuit.refresh_from_db() - circuit.termination_a = termination_z - circuit.termination_z = termination_a - circuit.save() - elif termination_a: - termination_a.term_side = 'Z' - termination_a.save() - circuit.refresh_from_db() - circuit.termination_a = None - circuit.save() - else: - termination_z.term_side = 'A' - termination_z.save() - circuit.refresh_from_db() - circuit.termination_z = None - circuit.save() - - messages.success(request, _("Swapped terminations for circuit {circuit}.").format(circuit=circuit)) - return redirect('circuits:circuit', pk=circuit.pk) - - return render(request, 'circuits/circuit_terminations_swap.html', { - 'circuit': circuit, - 'termination_a': circuit.termination_a, - 'termination_z': circuit.termination_z, - 'form': form, - 'panel_class': 'default', - 'button_class': 'primary', - 'return_url': circuit.get_absolute_url(), - }) - - # # Circuit terminations # diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index 365f006ab..0be895a47 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -14,9 +14,6 @@ {% trans "Edit" %} - - {% trans "Swap" %} - {% endif %} {% if termination and perms.circuits.delete_circuittermination %} From fd3a9a0c370d6b74a5255a9970b55f55827c6a9e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Oct 2025 17:14:55 -0400 Subject: [PATCH 038/125] Initial work on #20204 --- netbox/dcim/views.py | 24 ++- netbox/netbox/templates/__init__.py | 0 netbox/netbox/templates/components.py | 139 ++++++++++++++++++ .../components/attributes_panel.html | 13 ++ .../templates/components/gps_coordinates.html | 8 + .../templates/components/nested_object.html | 11 ++ netbox/templates/components/object.html | 26 ++++ netbox/templates/dcim/device.html | 1 + .../dcim/device/attrs/parent_device.html | 8 + netbox/templates/dcim/device/attrs/rack.html | 18 +++ 10 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 netbox/netbox/templates/__init__.py create mode 100644 netbox/netbox/templates/components.py create mode 100644 netbox/templates/components/attributes_panel.html create mode 100644 netbox/templates/components/gps_coordinates.html create mode 100644 netbox/templates/components/nested_object.html create mode 100644 netbox/templates/components/object.html create mode 100644 netbox/templates/dcim/device/attrs/parent_device.html create mode 100644 netbox/templates/dcim/device/attrs/rack.html diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index a41078a11..2439a3c06 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -16,6 +16,9 @@ from extras.views import ObjectConfigContextView, ObjectRenderConfigView from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from netbox.object_actions import * +from netbox.templates.components import ( + AttributesPanel, EmbeddedTemplate, GPSCoordinatesAttr, NestedObjectAttr, ObjectAttr, TextAttr, +) from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count @@ -2223,9 +2226,28 @@ class DeviceView(generic.ObjectView): else: vc_members = [] + device_attrs = AttributesPanel(_('Device'), { + _('Region'): NestedObjectAttr(instance.site.region, linkify=True), + _('Site'): ObjectAttr(instance.site, linkify=True, grouped_by='group'), + _('Location'): ObjectAttr(instance.location, linkify=True), + # TODO: Include position & face of parent device (if applicable) + _('Rack'): EmbeddedTemplate('dcim/device/attrs/rack.html', {'device': instance}), + _('Virtual Chassis'): ObjectAttr(instance.virtual_chassis, linkify=True), + _('Parent Device'): EmbeddedTemplate('dcim/device/attrs/parent_device.html', {'device': instance}), + _('GPS Coordinates'): GPSCoordinatesAttr(instance.latitude, instance.longitude), + _('Tenant'): ObjectAttr(instance.tenant, linkify=True, grouped_by='group'), + _('Device Type'): ObjectAttr(instance.device_type, linkify=True, grouped_by='manufacturer'), + _('Description'): TextAttr(instance.description), + _('Airflow'): TextAttr(instance.get_airflow_display()), + _('Serial Number'): TextAttr(instance.serial, style='font-monospace'), + _('Asset Tag'): TextAttr(instance.asset_tag, style='font-monospace'), + _('Config Template'): ObjectAttr(instance.config_template, linkify=True), + }) + return { 'vc_members': vc_members, - 'svg_extra': f'highlight=id:{instance.pk}' + 'svg_extra': f'highlight=id:{instance.pk}', + 'device_attrs': device_attrs, } diff --git a/netbox/netbox/templates/__init__.py b/netbox/netbox/templates/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/netbox/templates/components.py b/netbox/netbox/templates/components.py new file mode 100644 index 000000000..cb99b0e5a --- /dev/null +++ b/netbox/netbox/templates/components.py @@ -0,0 +1,139 @@ +from abc import ABC, abstractmethod + +from django.template.loader import render_to_string +from django.utils.html import escape +from django.utils.safestring import mark_safe + +from netbox.config import get_config + + +class Component(ABC): + + @abstractmethod + def render(self): + pass + + def __str__(self): + return self.render() + + +# +# Attributes +# + +class Attr(Component): + template_name = None + placeholder = mark_safe('') + + +class TextAttr(Attr): + + def __init__(self, value, style=None): + self.value = value + self.style = style + + def render(self): + if self.value in (None, ''): + return self.placeholder + if self.style: + return mark_safe(f'{escape(self.value)}') + return self.value + + +class ObjectAttr(Attr): + template_name = 'components/object.html' + + def __init__(self, obj, linkify=None, grouped_by=None, template_name=None): + self.object = obj + self.linkify = linkify + self.group = getattr(obj, grouped_by, None) if grouped_by else None + self.template_name = template_name or self.template_name + + def render(self): + if self.object is None: + return self.placeholder + + # Determine object & group URLs + # TODO: Add support for reverse() lookups + if self.linkify and hasattr(self.object, 'get_absolute_url'): + object_url = self.object.get_absolute_url() + else: + object_url = None + if self.linkify and hasattr(self.group, 'get_absolute_url'): + group_url = self.group.get_absolute_url() + else: + group_url = None + + return render_to_string(self.template_name, { + 'object': self.object, + 'object_url': object_url, + 'group': self.group, + 'group_url': group_url, + }) + + +class NestedObjectAttr(Attr): + template_name = 'components/nested_object.html' + + def __init__(self, obj, linkify=None): + self.object = obj + self.linkify = linkify + + def render(self): + if not self.object: + return self.placeholder + return render_to_string(self.template_name, { + 'nodes': self.object.get_ancestors(include_self=True), + 'linkify': self.linkify, + }) + + +class GPSCoordinatesAttr(Attr): + template_name = 'components/gps_coordinates.html' + + def __init__(self, latitude, longitude, map_url=True): + self.latitude = latitude + self.longitude = longitude + if map_url is True: + self.map_url = get_config().MAPS_URL + elif map_url: + self.map_url = map_url + else: + self.map_url = None + + def render(self): + if not (self.latitude and self.longitude): + return self.placeholder + return render_to_string(self.template_name, { + 'latitude': self.latitude, + 'longitude': self.longitude, + 'map_url': self.map_url, + }) + + +# +# Components +# + +class AttributesPanel(Component): + template_name = 'components/attributes_panel.html' + + def __init__(self, title, attrs): + self.title = title + self.attrs = attrs + + def render(self): + return render_to_string(self.template_name, { + 'title': self.title, + 'attrs': self.attrs, + }) + + +class EmbeddedTemplate(Component): + + def __init__(self, template_name, context=None): + self.template_name = template_name + self.context = context or {} + + def render(self): + return render_to_string(self.template_name, self.context) diff --git a/netbox/templates/components/attributes_panel.html b/netbox/templates/components/attributes_panel.html new file mode 100644 index 000000000..90c470b0d --- /dev/null +++ b/netbox/templates/components/attributes_panel.html @@ -0,0 +1,13 @@ +
+

{{ title }}

+ + {% for label, attr in attrs.items %} + + + + + {% endfor %} +
{{ label }} +
{{ attr }}
+
+
diff --git a/netbox/templates/components/gps_coordinates.html b/netbox/templates/components/gps_coordinates.html new file mode 100644 index 000000000..8e72f08bb --- /dev/null +++ b/netbox/templates/components/gps_coordinates.html @@ -0,0 +1,8 @@ +{% load i18n %} +{% load l10n %} +{{ latitude }}, {{ longitude }} +{% if map_url %} +
+ {% trans "Map" %} + +{% endif %} diff --git a/netbox/templates/components/nested_object.html b/netbox/templates/components/nested_object.html new file mode 100644 index 000000000..8cae08189 --- /dev/null +++ b/netbox/templates/components/nested_object.html @@ -0,0 +1,11 @@ + diff --git a/netbox/templates/components/object.html b/netbox/templates/components/object.html new file mode 100644 index 000000000..53702adc6 --- /dev/null +++ b/netbox/templates/components/object.html @@ -0,0 +1,26 @@ +{% if group %} + {# Display an object with its parent group #} + +{% else %} + {# Display only the object #} + {% if object_url %} + {{ object }} + {% else %} + {{ object }} + {% endif %} +{% endif %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index f8b8e95c2..03666dee9 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -177,6 +177,7 @@ {% plugin_left_page object %}
+ {{ device_attrs }}

{% trans "Management" %}

diff --git a/netbox/templates/dcim/device/attrs/parent_device.html b/netbox/templates/dcim/device/attrs/parent_device.html new file mode 100644 index 000000000..e8674e23b --- /dev/null +++ b/netbox/templates/dcim/device/attrs/parent_device.html @@ -0,0 +1,8 @@ +{% if device.parent_bay %} + +{% else %} + {{ ''|placeholder }} +{% endif %} diff --git a/netbox/templates/dcim/device/attrs/rack.html b/netbox/templates/dcim/device/attrs/rack.html new file mode 100644 index 000000000..41a031bad --- /dev/null +++ b/netbox/templates/dcim/device/attrs/rack.html @@ -0,0 +1,18 @@ +{% load i18n %} +{% if device.rack %} + + {{ device.rack|linkify }} + {% if device.rack and device.position %} + (U{{ device.position|floatformat }} / {{ device.get_face_display }}) + {% elif device.rack and device.device_type.u_height %} + {% trans "Not racked" %} + {% endif %} + + {% if device.rack and device.position %} + + + + {% endif %} +{% else %} + {{ ''|placeholder }} +{% endif %} From 3890043b0616f15c5f6af78e81c3c37d0eed52df Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Oct 2025 10:46:22 -0400 Subject: [PATCH 039/125] Change approach for declaring object panels --- netbox/dcim/template_components/__init__.py | 0 .../dcim/template_components/object_panels.py | 26 ++ netbox/dcim/views.py | 24 +- netbox/netbox/templates/components.py | 264 ++++++++++-------- netbox/templates/components/object.html | 18 +- ...s_panel.html => object_details_panel.html} | 6 +- netbox/templates/dcim/device.html | 2 +- .../dcim/device/attrs/parent_device.html | 2 +- netbox/templates/dcim/device/attrs/rack.html | 14 +- 9 files changed, 198 insertions(+), 158 deletions(-) create mode 100644 netbox/dcim/template_components/__init__.py create mode 100644 netbox/dcim/template_components/object_panels.py rename netbox/templates/components/{attributes_panel.html => object_details_panel.html} (70%) diff --git a/netbox/dcim/template_components/__init__.py b/netbox/dcim/template_components/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/dcim/template_components/object_panels.py b/netbox/dcim/template_components/object_panels.py new file mode 100644 index 000000000..b822b5b68 --- /dev/null +++ b/netbox/dcim/template_components/object_panels.py @@ -0,0 +1,26 @@ +from django.utils.translation import gettext_lazy as _ + +from netbox.templates.components import ( + GPSCoordinatesAttr, NestedObjectAttr, ObjectAttr, ObjectDetailsPanel, TemplatedAttr, TextAttr, +) + + +class DevicePanel(ObjectDetailsPanel): + region = NestedObjectAttr('site.region', linkify=True) + site = ObjectAttr('site', linkify=True, grouped_by='group') + location = NestedObjectAttr('location', linkify=True) + rack = TemplatedAttr('rack', template_name='dcim/device/attrs/rack.html') + virtual_chassis = NestedObjectAttr('virtual_chassis', linkify=True) + parent_device = TemplatedAttr( + 'parent_bay', + template_name='dcim/device/attrs/parent_device.html', + label=_('Parent Device'), + ) + gps_coordinates = GPSCoordinatesAttr() + tenant = ObjectAttr('tenant', linkify=True, grouped_by='group') + device_type = ObjectAttr('device_type', linkify=True, grouped_by='manufacturer') + description = TextAttr('description') + airflow = TextAttr('get_airflow_display') + serial = TextAttr('serial', style='font-monospace') + asset_tag = TextAttr('asset_tag', style='font-monospace') + config_template = ObjectAttr('config_template', linkify=True) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 2439a3c06..b146e3fe9 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -12,13 +12,11 @@ from django.utils.translation import gettext_lazy as _ from django.views.generic import View from circuits.models import Circuit, CircuitTermination +from dcim.template_components.object_panels import DevicePanel from extras.views import ObjectConfigContextView, ObjectRenderConfigView from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from netbox.object_actions import * -from netbox.templates.components import ( - AttributesPanel, EmbeddedTemplate, GPSCoordinatesAttr, NestedObjectAttr, ObjectAttr, TextAttr, -) from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count @@ -2226,28 +2224,10 @@ class DeviceView(generic.ObjectView): else: vc_members = [] - device_attrs = AttributesPanel(_('Device'), { - _('Region'): NestedObjectAttr(instance.site.region, linkify=True), - _('Site'): ObjectAttr(instance.site, linkify=True, grouped_by='group'), - _('Location'): ObjectAttr(instance.location, linkify=True), - # TODO: Include position & face of parent device (if applicable) - _('Rack'): EmbeddedTemplate('dcim/device/attrs/rack.html', {'device': instance}), - _('Virtual Chassis'): ObjectAttr(instance.virtual_chassis, linkify=True), - _('Parent Device'): EmbeddedTemplate('dcim/device/attrs/parent_device.html', {'device': instance}), - _('GPS Coordinates'): GPSCoordinatesAttr(instance.latitude, instance.longitude), - _('Tenant'): ObjectAttr(instance.tenant, linkify=True, grouped_by='group'), - _('Device Type'): ObjectAttr(instance.device_type, linkify=True, grouped_by='manufacturer'), - _('Description'): TextAttr(instance.description), - _('Airflow'): TextAttr(instance.get_airflow_display()), - _('Serial Number'): TextAttr(instance.serial, style='font-monospace'), - _('Asset Tag'): TextAttr(instance.asset_tag, style='font-monospace'), - _('Config Template'): ObjectAttr(instance.config_template, linkify=True), - }) - return { 'vc_members': vc_members, 'svg_extra': f'highlight=id:{instance.pk}', - 'device_attrs': device_attrs, + 'device_panel': DevicePanel(instance, _('Device')), } diff --git a/netbox/netbox/templates/components.py b/netbox/netbox/templates/components.py index cb99b0e5a..09edf1d59 100644 --- a/netbox/netbox/templates/components.py +++ b/netbox/netbox/templates/components.py @@ -1,12 +1,143 @@ -from abc import ABC, abstractmethod +from abc import ABC, ABCMeta, abstractmethod +from functools import cached_property from django.template.loader import render_to_string from django.utils.html import escape from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ from netbox.config import get_config +from utilities.string import title +# +# Attributes +# + +class Attr: + template_name = None + placeholder = mark_safe('') + + def __init__(self, accessor, label=None, template_name=None): + self.accessor = accessor + self.label = label + self.template_name = template_name or self.template_name + + @staticmethod + def _resolve_attr(obj, path): + cur = obj + for part in path.split('.'): + if cur is None: + return None + cur = getattr(cur, part) if hasattr(cur, part) else cur.get(part) if isinstance(cur, dict) else None + return cur + + +class TextAttr(Attr): + + def __init__(self, *args, style=None, **kwargs): + super().__init__(*args, **kwargs) + self.style = style + + def render(self, obj): + value = self._resolve_attr(obj, self.accessor) + if value in (None, ''): + return self.placeholder + if self.style: + return mark_safe(f'{escape(value)}') + return value + + +class ObjectAttr(Attr): + template_name = 'components/object.html' + + def __init__(self, *args, linkify=None, grouped_by=None, **kwargs): + super().__init__(*args, **kwargs) + self.linkify = linkify + self.grouped_by = grouped_by + + # Derive label from related object if not explicitly set + if self.label is None: + self.label = title(self.accessor) + + def render(self, obj): + value = self._resolve_attr(obj, self.accessor) + if value is None: + return self.placeholder + group = getattr(value, self.grouped_by, None) if self.grouped_by else None + + return render_to_string(self.template_name, { + 'object': value, + 'group': group, + 'linkify': self.linkify, + }) + + +class NestedObjectAttr(Attr): + template_name = 'components/nested_object.html' + + def __init__(self, *args, linkify=None, **kwargs): + super().__init__(*args, **kwargs) + self.linkify = linkify + + def render(self, obj): + value = self._resolve_attr(obj, self.accessor) + if value is None: + return self.placeholder + return render_to_string(self.template_name, { + 'nodes': value.get_ancestors(include_self=True), + 'linkify': self.linkify, + }) + + +class GPSCoordinatesAttr(Attr): + template_name = 'components/gps_coordinates.html' + + def __init__(self, latitude_attr='latitude', longitude_attr='longitude', map_url=True, **kwargs): + kwargs.setdefault('label', _('GPS Coordinates')) + super().__init__(accessor=None, **kwargs) + self.latitude_attr = latitude_attr + self.longitude_attr = longitude_attr + if map_url is True: + self.map_url = get_config().MAPS_URL + elif map_url: + self.map_url = map_url + else: + self.map_url = None + + def render(self, obj): + latitude = self._resolve_attr(obj, self.latitude_attr) + longitude = self._resolve_attr(obj, self.longitude_attr) + if latitude is None or longitude is None: + return self.placeholder + return render_to_string(self.template_name, { + 'latitude': latitude, + 'longitude': longitude, + 'map_url': self.map_url, + }) + + +class TemplatedAttr(Attr): + + def __init__(self, *args, context=None, **kwargs): + super().__init__(*args, **kwargs) + self.context = context or {} + + def render(self, obj): + return render_to_string( + self.template_name, + { + **self.context, + 'object': obj, + 'value': self._resolve_attr(obj, self.accessor), + } + ) + + +# +# Components +# + class Component(ABC): @abstractmethod @@ -17,123 +148,38 @@ class Component(ABC): return self.render() -# -# Attributes -# +class ObjectDetailsPanelMeta(ABCMeta): -class Attr(Component): - template_name = None - placeholder = mark_safe('') + def __new__(mcls, name, bases, attrs): + # Collect all declared attributes + attrs['_attrs'] = {} + for key, val in list(attrs.items()): + if isinstance(val, Attr): + attrs['_attrs'][key] = val + return super().__new__(mcls, name, bases, attrs) -class TextAttr(Attr): +class ObjectDetailsPanel(Component, metaclass=ObjectDetailsPanelMeta): + template_name = 'components/object_details_panel.html' - def __init__(self, value, style=None): - self.value = value - self.style = style - - def render(self): - if self.value in (None, ''): - return self.placeholder - if self.style: - return mark_safe(f'{escape(self.value)}') - return self.value - - -class ObjectAttr(Attr): - template_name = 'components/object.html' - - def __init__(self, obj, linkify=None, grouped_by=None, template_name=None): + def __init__(self, obj, title=None): self.object = obj - self.linkify = linkify - self.group = getattr(obj, grouped_by, None) if grouped_by else None - self.template_name = template_name or self.template_name + self.title = title or obj._meta.verbose_name - def render(self): - if self.object is None: - return self.placeholder - - # Determine object & group URLs - # TODO: Add support for reverse() lookups - if self.linkify and hasattr(self.object, 'get_absolute_url'): - object_url = self.object.get_absolute_url() - else: - object_url = None - if self.linkify and hasattr(self.group, 'get_absolute_url'): - group_url = self.group.get_absolute_url() - else: - group_url = None - - return render_to_string(self.template_name, { - 'object': self.object, - 'object_url': object_url, - 'group': self.group, - 'group_url': group_url, - }) - - -class NestedObjectAttr(Attr): - template_name = 'components/nested_object.html' - - def __init__(self, obj, linkify=None): - self.object = obj - self.linkify = linkify - - def render(self): - if not self.object: - return self.placeholder - return render_to_string(self.template_name, { - 'nodes': self.object.get_ancestors(include_self=True), - 'linkify': self.linkify, - }) - - -class GPSCoordinatesAttr(Attr): - template_name = 'components/gps_coordinates.html' - - def __init__(self, latitude, longitude, map_url=True): - self.latitude = latitude - self.longitude = longitude - if map_url is True: - self.map_url = get_config().MAPS_URL - elif map_url: - self.map_url = map_url - else: - self.map_url = None - - def render(self): - if not (self.latitude and self.longitude): - return self.placeholder - return render_to_string(self.template_name, { - 'latitude': self.latitude, - 'longitude': self.longitude, - 'map_url': self.map_url, - }) - - -# -# Components -# - -class AttributesPanel(Component): - template_name = 'components/attributes_panel.html' - - def __init__(self, title, attrs): - self.title = title - self.attrs = attrs + @cached_property + def attributes(self): + return [ + { + 'label': attr.label or title(name), + 'value': attr.render(self.object), + } for name, attr in self._attrs.items() + ] def render(self): return render_to_string(self.template_name, { 'title': self.title, - 'attrs': self.attrs, + 'attrs': self.attributes, }) - -class EmbeddedTemplate(Component): - - def __init__(self, template_name, context=None): - self.template_name = template_name - self.context = context or {} - - def render(self): - return render_to_string(self.template_name, self.context) + def __str__(self): + return self.render() diff --git a/netbox/templates/components/object.html b/netbox/templates/components/object.html index 53702adc6..55263138b 100644 --- a/netbox/templates/components/object.html +++ b/netbox/templates/components/object.html @@ -2,25 +2,13 @@ {# Display an object with its parent group #} {% else %} {# Display only the object #} - {% if object_url %} - {{ object }} - {% else %} - {{ object }} - {% endif %} + {% if linkify %}{{ object|linkify }}{% else %}{{ object }}{% endif %} {% endif %} diff --git a/netbox/templates/components/attributes_panel.html b/netbox/templates/components/object_details_panel.html similarity index 70% rename from netbox/templates/components/attributes_panel.html rename to netbox/templates/components/object_details_panel.html index 90c470b0d..def52f76a 100644 --- a/netbox/templates/components/attributes_panel.html +++ b/netbox/templates/components/object_details_panel.html @@ -1,11 +1,11 @@

{{ title }}

- {% for label, attr in attrs.items %} + {% for attr in attrs %} - + {% endfor %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 03666dee9..36700ccfe 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -177,7 +177,7 @@ {% plugin_left_page object %}
- {{ device_attrs }} + {{ device_panel }}

{% trans "Management" %}

{{ label }}{{ attr.label }} -
{{ attr }}
+
{{ attr.value }}
diff --git a/netbox/templates/dcim/device/attrs/parent_device.html b/netbox/templates/dcim/device/attrs/parent_device.html index e8674e23b..375a511c4 100644 --- a/netbox/templates/dcim/device/attrs/parent_device.html +++ b/netbox/templates/dcim/device/attrs/parent_device.html @@ -1,4 +1,4 @@ -{% if device.parent_bay %} +{% if value %}
- - - - - - - - - - - - - {% if object.virtual_chassis %} - - - - - {% endif %} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{% trans "Region" %}{% nested_tree object.site.region %}
{% trans "Site" %}{{ object.site|linkify }}
{% trans "Location" %}{% nested_tree object.location %}
{% trans "Virtual Chassis" %}{{ object.virtual_chassis|linkify }}
{% trans "Rack" %} - {% if object.rack %} - {{ object.rack|linkify }} - - - - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Position" %} - {% if object.parent_bay %} - {% with object.parent_bay.device as parent %} - {{ parent|linkify }} / {{ object.parent_bay }} - {% if parent.position %} - (U{{ parent.position|floatformat }} / {{ parent.get_face_display }}) - {% endif %} - {% endwith %} - {% elif object.rack and object.position %} - U{{ object.position|floatformat }} / {{ object.get_face_display }} - {% elif object.rack and object.device_type.u_height %} - {% trans "Not racked" %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "GPS Coordinates" %} - {% if object.latitude and object.longitude %} - {% if config.MAPS_URL %} - - {% endif %} - {{ object.latitude }}, {{ object.longitude }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Tenant" %} - {% if object.tenant.group %} - {{ object.tenant.group|linkify }} / - {% endif %} - {{ object.tenant|linkify|placeholder }} -
{% trans "Device Type" %} - {{ object.device_type|linkify:"full_name" }} ({{ object.device_type.u_height|floatformat }}U) -
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Airflow" %} - {{ object.get_airflow_display|placeholder }} -
{% trans "Serial Number" %}{{ object.serial|placeholder }}
{% trans "Asset Tag" %}{{ object.asset_tag|placeholder }}
{% trans "Config Template" %}{{ object.config_template|linkify|placeholder }}
-
+ {{ device_panel }} {% if vc_members %}

@@ -177,83 +69,7 @@ {% plugin_left_page object %}

- {{ device_panel }} -
-

{% trans "Management" %}

- - - - - - - - - - - - - - - - - - - - - - - - - - {% if object.cluster %} - - - - - {% endif %} -
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Role" %}{{ object.role|linkify }}
{% trans "Platform" %}{{ object.platform|linkify|placeholder }}
{% trans "Primary IPv4" %} - {% if object.primary_ip4 %} - {{ object.primary_ip4.address.ip }} - {% if object.primary_ip4.nat_inside %} - ({% trans "NAT for" %} {{ object.primary_ip4.nat_inside.address.ip }}) - {% elif object.primary_ip4.nat_outside.exists %} - ({% trans "NAT" %}: {% for nat in object.primary_ip4.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) - {% endif %} - {% copy_content "primary_ip4" %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Primary IPv6" %} - {% if object.primary_ip6 %} - {{ object.primary_ip6.address.ip }} - {% if object.primary_ip6.nat_inside %} - ({% trans "NAT for" %} {{ object.primary_ip6.nat_inside.address.ip }}) - {% elif object.primary_ip6.nat_outside.exists %} - ({% trans "NAT" %}: {% for nat in object.primary_ip6.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) - {% endif %} - {% copy_content "primary_ip6" %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
Out-of-band IP - {% if object.oob_ip %} - {{ object.oob_ip.address.ip }} - {% if object.oob_ip.nat_inside %} - ({% trans "NAT for" %} {{ object.oob_ip.nat_inside.address.ip }}) - {% elif object.oob_ip.nat_outside.exists %} - ({% trans "NAT" %}: {% for nat in object.oob_ip.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) - {% endif %} - {% copy_content "oob_ip" %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Cluster" %} - {% if object.cluster.group %} - {{ object.cluster.group|linkify }} / - {% endif %} - {{ object.cluster|linkify }} -
-
+ {{ management_panel }} {% if object.powerports.exists and object.poweroutlets.exists %}

{% trans "Power Utilization" %}

diff --git a/netbox/templates/dcim/device/attrs/ipaddress.html b/netbox/templates/dcim/device/attrs/ipaddress.html new file mode 100644 index 000000000..2af5dab6c --- /dev/null +++ b/netbox/templates/dcim/device/attrs/ipaddress.html @@ -0,0 +1,11 @@ +{# TODO: Add copy-to-clipboard button #} +{% load i18n %} +{{ value.address.ip }} +{% if value.nat_inside %} + ({% trans "NAT for" %} {{ value.nat_inside.address.ip }}) +{% elif value.nat_outside.exists %} + ({% trans "NAT" %}: {% for nat in value.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) +{% endif %} + + + diff --git a/netbox/templates/dcim/device/attrs/parent_device.html b/netbox/templates/dcim/device/attrs/parent_device.html index 375a511c4..6351f792a 100644 --- a/netbox/templates/dcim/device/attrs/parent_device.html +++ b/netbox/templates/dcim/device/attrs/parent_device.html @@ -1,8 +1,10 @@ -{% if value %} - -{% else %} - {{ ''|placeholder }} +{% load i18n %} + +{% if value.device.position %} + + + {% endif %} diff --git a/netbox/templates/dcim/device/attrs/rack.html b/netbox/templates/dcim/device/attrs/rack.html index b10780252..d939e9ca3 100644 --- a/netbox/templates/dcim/device/attrs/rack.html +++ b/netbox/templates/dcim/device/attrs/rack.html @@ -1,18 +1,14 @@ {% load i18n %} -{% if value %} - - {{ value|linkify }} - {% if value and object.position %} - (U{{ object.position|floatformat }} / {{ object.get_face_display }}) - {% elif value and object.device_type.u_height %} - {% trans "Not racked" %} - {% endif %} - + + {{ value|linkify }} {% if value and object.position %} - - - + (U{{ object.position|floatformat }} / {{ object.get_face_display }}) + {% elif value and object.device_type.u_height %} + {% trans "Not racked" %} {% endif %} -{% else %} - {{ ''|placeholder }} + +{% if object.position %} + + + {% endif %} From 1acd567706eeed03fe1f6c865c113301605f903c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Oct 2025 15:29:23 -0400 Subject: [PATCH 042/125] Add site panel --- netbox/dcim/ui/panels.py | 13 +++ netbox/dcim/views.py | 1 + netbox/netbox/ui/attrs.py | 38 ++++++++ .../templates/components/attrs/address.html | 8 ++ .../templates/components/attrs/timezone.html | 6 ++ netbox/templates/dcim/site.html | 96 ++----------------- 6 files changed, 72 insertions(+), 90 deletions(-) create mode 100644 netbox/templates/components/attrs/address.html create mode 100644 netbox/templates/components/attrs/timezone.html diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index 8f0d3b90d..56a557acb 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -44,3 +44,16 @@ class DeviceManagementPanel(ObjectPanel): label=_('Out-of-band IP'), template_name='dcim/device/attrs/ipaddress.html', ) + + +class SitePanel(ObjectPanel): + region = attrs.NestedObjectAttr('region', label=_('Region'), linkify=True) + group = attrs.NestedObjectAttr('group', label=_('Group'), linkify=True) + status = attrs.ChoiceAttr('status', label=_('Status')) + tenant = attrs.ObjectAttr('tenant', label=_('Tenant'), linkify=True, grouped_by='group') + facility = attrs.TextAttr('facility', label=_('Facility')) + description = attrs.TextAttr('description', label=_('Description')) + timezone = attrs.TimezoneAttr('time_zone', label=_('Timezone')) + physical_address = attrs.AddressAttr('physical_address', label=_('Physical address'), map_url=True) + shipping_address = attrs.AddressAttr('shipping_address', label=_('Shipping address'), map_url=True) + gps_coordinates = attrs.GPSCoordinatesAttr() diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index d7ebeca11..4e751d33f 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -465,6 +465,7 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView): def get_extra_context(self, request, instance): return { + 'site_panel': panels.SitePanel(instance, _('Site')), 'related_models': self.get_related_models( request, instance, diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index 233beef60..679cc5d66 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -123,6 +123,30 @@ class NestedObjectAttr(Attr): }) +class AddressAttr(Attr): + template_name = 'components/attrs/address.html' + + def __init__(self, *args, map_url=True, **kwargs): + super().__init__(*args, **kwargs) + if map_url is True: + self.map_url = get_config().MAPS_URL + elif map_url: + self.map_url = map_url + else: + self.map_url = None + + def render(self, obj, context=None): + context = context or {} + value = self._resolve_attr(obj, self.accessor) + if value in (None, ''): + return self.placeholder + return render_to_string(self.template_name, { + **context, + 'value': value, + 'map_url': self.map_url, + }) + + class GPSCoordinatesAttr(Attr): template_name = 'components/attrs/gps_coordinates.html' @@ -152,6 +176,20 @@ class GPSCoordinatesAttr(Attr): }) +class TimezoneAttr(Attr): + template_name = 'components/attrs/timezone.html' + + def render(self, obj, context=None): + context = context or {} + value = self._resolve_attr(obj, self.accessor) + if value in (None, ''): + return self.placeholder + return render_to_string(self.template_name, { + **context, + 'value': value, + }) + + class TemplatedAttr(Attr): def __init__(self, *args, context=None, **kwargs): diff --git a/netbox/templates/components/attrs/address.html b/netbox/templates/components/attrs/address.html new file mode 100644 index 000000000..08f46fc43 --- /dev/null +++ b/netbox/templates/components/attrs/address.html @@ -0,0 +1,8 @@ +{% load i18n %} +{% load l10n %} +{{ value|linebreaksbr }} +{% if map_url %} + + {% trans "Map" %} + +{% endif %} diff --git a/netbox/templates/components/attrs/timezone.html b/netbox/templates/components/attrs/timezone.html new file mode 100644 index 000000000..7492d72ad --- /dev/null +++ b/netbox/templates/components/attrs/timezone.html @@ -0,0 +1,6 @@ +{% load i18n %} +{% load tz %} +
+ {{ value }} ({% trans "UTC" %} {{ value|tzoffset }})
+ {% trans "Local time" %}: {% timezone value %}{% now 'Y-m-d H:i' %}{% endtimezone %} +
diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index cf65961d9..f4e9a5d02 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -24,100 +24,16 @@ {% block content %}
-
-

{% trans "Site" %}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{% trans "Region" %} - {% nested_tree object.region %} -
{% trans "Group" %} - {% nested_tree object.group %} -
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Tenant" %} - {% if object.tenant.group %} - {{ object.tenant.group|linkify }} / - {% endif %} - {{ object.tenant|linkify|placeholder }} -
{% trans "Facility" %}{{ object.facility|placeholder }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Time Zone" %} - {% if object.time_zone %} - {{ object.time_zone }} ({% trans "UTC" %} {{ object.time_zone|tzoffset }})
- {% trans "Site time" %}: {% timezone object.time_zone %}{% now 'Y-m-d H:i' %}{% endtimezone %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Physical Address" %} - {% if object.physical_address %} - {{ object.physical_address|linebreaksbr }} - {% if config.MAPS_URL %} - - {% trans "Map" %} - - {% endif %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Shipping Address" %}{{ object.shipping_address|linebreaksbr|placeholder }}
{% trans "GPS Coordinates" %} - {% if object.latitude and object.longitude %} - {% if config.MAPS_URL %} - - {% endif %} - {{ object.latitude }}, {{ object.longitude }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
-
+ {{ site_panel }} {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% plugin_left_page object %} -
-
- {% include 'inc/panels/related_objects.html' with filter_name='site_id' %} - {% include 'inc/panels/image_attachments.html' %} - {% plugin_right_page object %} +
+
+ {% include 'inc/panels/related_objects.html' with filter_name='site_id' %} + {% include 'inc/panels/image_attachments.html' %} + {% plugin_right_page object %}
From 83de78419632ccb74f75730cd80677f608933b54 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Oct 2025 15:34:44 -0400 Subject: [PATCH 043/125] Add region & site group panels --- netbox/dcim/views.py | 3 +++ netbox/netbox/ui/components.py | 8 ++++++++ netbox/templates/dcim/region.html | 18 +----------------- netbox/templates/dcim/sitegroup.html | 18 +----------------- 4 files changed, 13 insertions(+), 34 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 4e751d33f..e32a59a0b 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -17,6 +17,7 @@ from extras.views import ObjectConfigContextView, ObjectRenderConfigView from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from netbox.object_actions import * +from netbox.ui.components import NestedGroupObjectPanel from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count @@ -227,6 +228,7 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView): regions = instance.get_descendants(include_self=True) return { + 'region_panel': NestedGroupObjectPanel(instance, _('Region')), 'related_models': self.get_related_models( request, regions, @@ -338,6 +340,7 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView): groups = instance.get_descendants(include_self=True) return { + 'sitegroup_panel': NestedGroupObjectPanel(instance, _('Site Group')), 'related_models': self.get_related_models( request, groups, diff --git a/netbox/netbox/ui/components.py b/netbox/netbox/ui/components.py index c3aa1ae66..206086f17 100644 --- a/netbox/netbox/ui/components.py +++ b/netbox/netbox/ui/components.py @@ -2,7 +2,9 @@ from abc import ABC, ABCMeta, abstractmethod from functools import cached_property from django.template.loader import render_to_string +from django.utils.translation import gettext_lazy as _ +from netbox.ui import attrs from netbox.ui.attrs import Attr from utilities.string import title @@ -52,3 +54,9 @@ class ObjectPanel(Component, metaclass=ObjectDetailsPanelMeta): def __str__(self): return self.render() + + +class NestedGroupObjectPanel(ObjectPanel): + name = attrs.TextAttr('name', label=_('Name')) + description = attrs.TextAttr('description', label=_('Description')) + parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True) diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html index f11868b0a..28f4b6127 100644 --- a/netbox/templates/dcim/region.html +++ b/netbox/templates/dcim/region.html @@ -22,23 +22,7 @@ {% block content %}
-
-

{% trans "Region" %}

- - - - - - - - - - - - - -
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Parent" %}{{ object.parent|linkify|placeholder }}
-
+ {{ region_panel }} {% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/comments.html' %} diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html index dc9aca6f5..63e240dc6 100644 --- a/netbox/templates/dcim/sitegroup.html +++ b/netbox/templates/dcim/sitegroup.html @@ -22,23 +22,7 @@ {% block content %}
-
-

{% trans "Site Group" %}

- - - - - - - - - - - - - -
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Parent" %}{{ object.parent|linkify|placeholder }}
-
+ {{ sitegroup_panel }} {% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/comments.html' %} From 2a629d6f745b8e6840a9926326033ed10f326c8a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Oct 2025 16:25:42 -0400 Subject: [PATCH 044/125] Enable panel inheritance; add location panel --- netbox/dcim/ui/panels.py | 16 ++++++++---- netbox/dcim/views.py | 1 + netbox/netbox/ui/components.py | 32 +++++++++++++++++------ netbox/templates/dcim/location.html | 39 +---------------------------- 4 files changed, 37 insertions(+), 51 deletions(-) diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index 56a557acb..c7aa36a23 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -1,10 +1,16 @@ from django.utils.translation import gettext_lazy as _ -from netbox.ui import attrs -from netbox.ui.components import ObjectPanel +from netbox.ui import attrs, components -class DevicePanel(ObjectPanel): +class LocationPanel(components.NestedGroupObjectPanel): + site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') + status = attrs.ChoiceAttr('status', label=_('Status')) + tenant = attrs.ObjectAttr('tenant', label=_('Tenant'), linkify=True, grouped_by='group') + facility = attrs.TextAttr('facility', label=_('Facility')) + + +class DevicePanel(components.ObjectPanel): region = attrs.NestedObjectAttr('site.region', label=_('Region'), linkify=True) site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') location = attrs.NestedObjectAttr('location', label=_('Location'), linkify=True) @@ -25,7 +31,7 @@ class DevicePanel(ObjectPanel): config_template = attrs.ObjectAttr('config_template', label=_('Config template'), linkify=True) -class DeviceManagementPanel(ObjectPanel): +class DeviceManagementPanel(components.ObjectPanel): status = attrs.ChoiceAttr('status', label=_('Status')) role = attrs.NestedObjectAttr('role', label=_('Role'), linkify=True, max_depth=3) platform = attrs.NestedObjectAttr('platform', label=_('Platform'), linkify=True, max_depth=3) @@ -46,7 +52,7 @@ class DeviceManagementPanel(ObjectPanel): ) -class SitePanel(ObjectPanel): +class SitePanel(components.ObjectPanel): region = attrs.NestedObjectAttr('region', label=_('Region'), linkify=True) group = attrs.NestedObjectAttr('group', label=_('Group'), linkify=True) status = attrs.ChoiceAttr('status', label=_('Status')) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index e32a59a0b..eb0e42ee1 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -571,6 +571,7 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView): locations = instance.get_descendants(include_self=True) location_content_type = ContentType.objects.get_for_model(instance) return { + 'location_panel': panels.LocationPanel(instance, _('Location')), 'related_models': self.get_related_models( request, locations, diff --git a/netbox/netbox/ui/components.py b/netbox/netbox/ui/components.py index 206086f17..156eb0304 100644 --- a/netbox/netbox/ui/components.py +++ b/netbox/netbox/ui/components.py @@ -21,13 +21,29 @@ class Component(ABC): class ObjectDetailsPanelMeta(ABCMeta): - def __new__(mcls, name, bases, attrs): - # Collect all declared attributes - attrs['_attrs'] = {} - for key, val in list(attrs.items()): - if isinstance(val, Attr): - attrs['_attrs'][key] = val - return super().__new__(mcls, name, bases, attrs) + def __new__(mcls, name, bases, namespace, **kwargs): + declared = {} + + # Walk MRO parents (excluding `object`) for declared attributes + for base in reversed([b for b in bases if hasattr(b, "_attrs")]): + for key, attr in getattr(base, '_attrs', {}).items(): + if key not in declared: + declared[key] = attr + + # Add local declarations in the order they appear in the class body + for key, attr in namespace.items(): + if isinstance(attr, Attr): + declared[key] = attr + + namespace['_attrs'] = declared + + # Remove Attrs from the class namespace to keep things tidy + local_items = [key for key, attr in namespace.items() if isinstance(attr, Attr)] + for key in local_items: + namespace.pop(key) + + cls = super().__new__(mcls, name, bases, namespace, **kwargs) + return cls class ObjectPanel(Component, metaclass=ObjectDetailsPanelMeta): @@ -56,7 +72,7 @@ class ObjectPanel(Component, metaclass=ObjectDetailsPanelMeta): return self.render() -class NestedGroupObjectPanel(ObjectPanel): +class NestedGroupObjectPanel(ObjectPanel, metaclass=ObjectDetailsPanelMeta): name = attrs.TextAttr('name', label=_('Name')) description = attrs.TextAttr('description', label=_('Description')) parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True) diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index dfd0c32b3..861a2adef 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -22,44 +22,7 @@ {% block content %}
-
-

{% trans "Location" %}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Site" %}{{ object.site|linkify }}
{% trans "Parent" %}{{ object.parent|linkify|placeholder }}
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Tenant" %} - {% if object.tenant.group %} - {{ object.tenant.group|linkify }} / - {% endif %} - {{ object.tenant|linkify|placeholder }} -
{% trans "Facility" %}{{ object.facility|placeholder }}
-
+ {{ location_panel }} {% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/comments.html' %} From 90874adf148ef300dd639f5aafa9a706dfa500dd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Oct 2025 16:53:00 -0400 Subject: [PATCH 045/125] Add rack panel --- netbox/dcim/ui/panels.py | 43 +++++++++++++------ netbox/dcim/views.py | 1 + netbox/netbox/ui/attrs.py | 12 ++++++ .../components/attrs/utilization.html | 2 + netbox/templates/dcim/rack.html | 1 + 5 files changed, 46 insertions(+), 13 deletions(-) create mode 100644 netbox/templates/components/attrs/utilization.html diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index c7aa36a23..6ffd2e3d4 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -3,6 +3,19 @@ from django.utils.translation import gettext_lazy as _ from netbox.ui import attrs, components +class SitePanel(components.ObjectPanel): + region = attrs.NestedObjectAttr('region', label=_('Region'), linkify=True) + group = attrs.NestedObjectAttr('group', label=_('Group'), linkify=True) + status = attrs.ChoiceAttr('status', label=_('Status')) + tenant = attrs.ObjectAttr('tenant', label=_('Tenant'), linkify=True, grouped_by='group') + facility = attrs.TextAttr('facility', label=_('Facility')) + description = attrs.TextAttr('description', label=_('Description')) + timezone = attrs.TimezoneAttr('time_zone', label=_('Timezone')) + physical_address = attrs.AddressAttr('physical_address', label=_('Physical address'), map_url=True) + shipping_address = attrs.AddressAttr('shipping_address', label=_('Shipping address'), map_url=True) + gps_coordinates = attrs.GPSCoordinatesAttr() + + class LocationPanel(components.NestedGroupObjectPanel): site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') status = attrs.ChoiceAttr('status', label=_('Status')) @@ -10,6 +23,23 @@ class LocationPanel(components.NestedGroupObjectPanel): facility = attrs.TextAttr('facility', label=_('Facility')) +class RackPanel(components.ObjectPanel): + region = attrs.NestedObjectAttr('site.region', label=_('Region'), linkify=True) + site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') + location = attrs.NestedObjectAttr('location', label=_('Location'), linkify=True) + facility = attrs.TextAttr('facility', label=_('Facility')) + tenant = attrs.ObjectAttr('tenant', label=_('Tenant'), linkify=True, grouped_by='group') + status = attrs.ChoiceAttr('status', label=_('Status')) + rack_type = attrs.ObjectAttr('rack_type', label=_('Rack type'), linkify=True, grouped_by='manufacturer') + role = attrs.ObjectAttr('role', label=_('Role'), linkify=True) + description = attrs.TextAttr('description', label=_('Description')) + serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True) + asset_tag = attrs.TextAttr('asset_tag', label=_('Asset tag'), style='font-monospace', copy_button=True) + airflow = attrs.ChoiceAttr('airflow', label=_('Airflow')) + space_utilization = attrs.UtilizationAttr('get_utilization', label=_('Space utilization')) + power_utilization = attrs.UtilizationAttr('get_power_utilization', label=_('Power utilization')) + + class DevicePanel(components.ObjectPanel): region = attrs.NestedObjectAttr('site.region', label=_('Region'), linkify=True) site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') @@ -50,16 +80,3 @@ class DeviceManagementPanel(components.ObjectPanel): label=_('Out-of-band IP'), template_name='dcim/device/attrs/ipaddress.html', ) - - -class SitePanel(components.ObjectPanel): - region = attrs.NestedObjectAttr('region', label=_('Region'), linkify=True) - group = attrs.NestedObjectAttr('group', label=_('Group'), linkify=True) - status = attrs.ChoiceAttr('status', label=_('Status')) - tenant = attrs.ObjectAttr('tenant', label=_('Tenant'), linkify=True, grouped_by='group') - facility = attrs.TextAttr('facility', label=_('Facility')) - description = attrs.TextAttr('description', label=_('Description')) - timezone = attrs.TimezoneAttr('time_zone', label=_('Timezone')) - physical_address = attrs.AddressAttr('physical_address', label=_('Physical address'), map_url=True) - shipping_address = attrs.AddressAttr('shipping_address', label=_('Shipping address'), map_url=True) - gps_coordinates = attrs.GPSCoordinatesAttr() diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index eb0e42ee1..f59d8babd 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -868,6 +868,7 @@ class RackView(GetRelatedModelsMixin, generic.ObjectView): ]) return { + 'rack_panel': panels.RackPanel(instance, _('Rack')), 'related_models': self.get_related_models( request, instance, diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index 679cc5d66..293cfa29e 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -210,3 +210,15 @@ class TemplatedAttr(Attr): 'value': value, } ) + + +class UtilizationAttr(Attr): + template_name = 'components/attrs/utilization.html' + + def render(self, obj, context=None): + context = context or {} + value = self._resolve_attr(obj, self.accessor) + return render_to_string(self.template_name, { + **context, + 'value': value, + }) diff --git a/netbox/templates/components/attrs/utilization.html b/netbox/templates/components/attrs/utilization.html new file mode 100644 index 000000000..6e1db73f1 --- /dev/null +++ b/netbox/templates/components/attrs/utilization.html @@ -0,0 +1,2 @@ +{% load helpers %} +{% utilization_graph value %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index eec4d63a5..e0f60eb50 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -120,6 +120,7 @@ {% plugin_left_page object %}
+ {{ rack_panel }}
From 3fd4664a7605257c8268582ee2db9ddedd08d9dd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Oct 2025 13:48:24 -0400 Subject: [PATCH 047/125] Implement layout declaration under view --- netbox/dcim/ui/panels.py | 12 ++--- netbox/dcim/views.py | 15 +++++-- netbox/netbox/ui/attrs.py | 16 +++---- netbox/netbox/ui/layout.py | 44 +++++++++++++++++++ netbox/netbox/ui/{components.py => panels.py} | 40 ++++++++--------- netbox/netbox/views/generic/object_views.py | 5 +++ netbox/templates/generic/object.html | 15 ++++++- .../{components => ui}/attrs/address.html | 0 .../{components => ui}/attrs/choice.html | 0 .../attrs/gps_coordinates.html | 0 .../attrs/nested_object.html | 0 .../{components => ui}/attrs/object.html | 0 .../{components => ui}/attrs/text.html | 0 .../{components => ui}/attrs/timezone.html | 0 .../{components => ui}/attrs/utilization.html | 0 .../panels/object.html} | 0 .../utilities/templatetags/builtins/tags.py | 6 +++ 17 files changed, 113 insertions(+), 40 deletions(-) create mode 100644 netbox/netbox/ui/layout.py rename netbox/netbox/ui/{components.py => panels.py} (69%) rename netbox/templates/{components => ui}/attrs/address.html (100%) rename netbox/templates/{components => ui}/attrs/choice.html (100%) rename netbox/templates/{components => ui}/attrs/gps_coordinates.html (100%) rename netbox/templates/{components => ui}/attrs/nested_object.html (100%) rename netbox/templates/{components => ui}/attrs/object.html (100%) rename netbox/templates/{components => ui}/attrs/text.html (100%) rename netbox/templates/{components => ui}/attrs/timezone.html (100%) rename netbox/templates/{components => ui}/attrs/utilization.html (100%) rename netbox/templates/{components/object_details_panel.html => ui/panels/object.html} (100%) diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index 6ffd2e3d4..0ed917c55 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -1,9 +1,9 @@ from django.utils.translation import gettext_lazy as _ -from netbox.ui import attrs, components +from netbox.ui import attrs, panels -class SitePanel(components.ObjectPanel): +class SitePanel(panels.ObjectPanel): region = attrs.NestedObjectAttr('region', label=_('Region'), linkify=True) group = attrs.NestedObjectAttr('group', label=_('Group'), linkify=True) status = attrs.ChoiceAttr('status', label=_('Status')) @@ -16,14 +16,14 @@ class SitePanel(components.ObjectPanel): gps_coordinates = attrs.GPSCoordinatesAttr() -class LocationPanel(components.NestedGroupObjectPanel): +class LocationPanel(panels.NestedGroupObjectPanel): site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') status = attrs.ChoiceAttr('status', label=_('Status')) tenant = attrs.ObjectAttr('tenant', label=_('Tenant'), linkify=True, grouped_by='group') facility = attrs.TextAttr('facility', label=_('Facility')) -class RackPanel(components.ObjectPanel): +class RackPanel(panels.ObjectPanel): region = attrs.NestedObjectAttr('site.region', label=_('Region'), linkify=True) site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') location = attrs.NestedObjectAttr('location', label=_('Location'), linkify=True) @@ -40,7 +40,7 @@ class RackPanel(components.ObjectPanel): power_utilization = attrs.UtilizationAttr('get_power_utilization', label=_('Power utilization')) -class DevicePanel(components.ObjectPanel): +class DevicePanel(panels.ObjectPanel): region = attrs.NestedObjectAttr('site.region', label=_('Region'), linkify=True) site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') location = attrs.NestedObjectAttr('location', label=_('Location'), linkify=True) @@ -61,7 +61,7 @@ class DevicePanel(components.ObjectPanel): config_template = attrs.ObjectAttr('config_template', label=_('Config template'), linkify=True) -class DeviceManagementPanel(components.ObjectPanel): +class DeviceManagementPanel(panels.ObjectPanel): status = attrs.ChoiceAttr('status', label=_('Status')) role = attrs.NestedObjectAttr('role', label=_('Role'), linkify=True, max_depth=3) platform = attrs.NestedObjectAttr('platform', label=_('Platform'), linkify=True, max_depth=3) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index f59d8babd..d5382fa26 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -17,7 +17,7 @@ from extras.views import ObjectConfigContextView, ObjectRenderConfigView from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from netbox.object_actions import * -from netbox.ui.components import NestedGroupObjectPanel +from netbox.ui import layout from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count @@ -228,7 +228,7 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView): regions = instance.get_descendants(include_self=True) return { - 'region_panel': NestedGroupObjectPanel(instance, _('Region')), + 'region_panel': panels.NestedGroupObjectPanel(instance, _('Region')), 'related_models': self.get_related_models( request, regions, @@ -340,7 +340,7 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView): groups = instance.get_descendants(include_self=True) return { - 'sitegroup_panel': NestedGroupObjectPanel(instance, _('Site Group')), + 'sitegroup_panel': panels.NestedGroupObjectPanel(instance, _('Site Group')), 'related_models': self.get_related_models( request, groups, @@ -465,10 +465,17 @@ class SiteListView(generic.ObjectListView): @register_model_view(Site) class SiteView(GetRelatedModelsMixin, generic.ObjectView): queryset = Site.objects.prefetch_related('tenant__group') + layout = layout.Layout( + layout.Row( + layout.Column( + panels.SitePanel(_('Site')) + ), + ) + ) def get_extra_context(self, request, instance): return { - 'site_panel': panels.SitePanel(instance, _('Site')), + # 'site_panel': panels.SitePanel(instance, _('Site')), 'related_models': self.get_related_models( request, instance, diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index 293cfa29e..2e931d714 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -35,7 +35,7 @@ class Attr(ABC): class TextAttr(Attr): - template_name = 'components/attrs/text.html' + template_name = 'ui/attrs/text.html' def __init__(self, *args, style=None, copy_button=False, **kwargs): super().__init__(*args, **kwargs) @@ -56,7 +56,7 @@ class TextAttr(Attr): class ChoiceAttr(Attr): - template_name = 'components/attrs/choice.html' + template_name = 'ui/attrs/choice.html' def render(self, obj, context=None): context = context or {} @@ -78,7 +78,7 @@ class ChoiceAttr(Attr): class ObjectAttr(Attr): - template_name = 'components/attrs/object.html' + template_name = 'ui/attrs/object.html' def __init__(self, *args, linkify=None, grouped_by=None, **kwargs): super().__init__(*args, **kwargs) @@ -101,7 +101,7 @@ class ObjectAttr(Attr): class NestedObjectAttr(Attr): - template_name = 'components/attrs/nested_object.html' + template_name = 'ui/attrs/nested_object.html' def __init__(self, *args, linkify=None, max_depth=None, **kwargs): super().__init__(*args, **kwargs) @@ -124,7 +124,7 @@ class NestedObjectAttr(Attr): class AddressAttr(Attr): - template_name = 'components/attrs/address.html' + template_name = 'ui/attrs/address.html' def __init__(self, *args, map_url=True, **kwargs): super().__init__(*args, **kwargs) @@ -148,7 +148,7 @@ class AddressAttr(Attr): class GPSCoordinatesAttr(Attr): - template_name = 'components/attrs/gps_coordinates.html' + template_name = 'ui/attrs/gps_coordinates.html' def __init__(self, latitude_attr='latitude', longitude_attr='longitude', map_url=True, **kwargs): kwargs.setdefault('label', _('GPS Coordinates')) @@ -177,7 +177,7 @@ class GPSCoordinatesAttr(Attr): class TimezoneAttr(Attr): - template_name = 'components/attrs/timezone.html' + template_name = 'ui/attrs/timezone.html' def render(self, obj, context=None): context = context or {} @@ -213,7 +213,7 @@ class TemplatedAttr(Attr): class UtilizationAttr(Attr): - template_name = 'components/attrs/utilization.html' + template_name = 'ui/attrs/utilization.html' def render(self, obj, context=None): context = context or {} diff --git a/netbox/netbox/ui/layout.py b/netbox/netbox/ui/layout.py new file mode 100644 index 000000000..1ff362e32 --- /dev/null +++ b/netbox/netbox/ui/layout.py @@ -0,0 +1,44 @@ +from netbox.ui.panels import Panel + +__all__ = ( + 'Column', + 'Layout', + 'Row', +) + + +class Layout: + + def __init__(self, *rows): + for i, row in enumerate(rows): + if type(row) is not Row: + raise TypeError(f"Row {i} must be a Row instance, not {type(row)}.") + self.rows = rows + + def render(self, context): + return ''.join([row.render(context) for row in self.rows]) + + +class Row: + template_name = 'ui/layout/row.html' + + def __init__(self, *columns): + for i, column in enumerate(columns): + if type(column) is not Column: + raise TypeError(f"Column {i} must be a Column instance, not {type(column)}.") + self.columns = columns + + def render(self, context): + return ''.join([column.render(context) for column in self.columns]) + + +class Column: + + def __init__(self, *panels): + for i, panel in enumerate(panels): + if not isinstance(panel, Panel): + raise TypeError(f"Panel {i} must be an instance of a Panel, not {type(panel)}.") + self.panels = panels + + def render(self, context): + return ''.join([panel.render(context) for panel in self.panels]) diff --git a/netbox/netbox/ui/components.py b/netbox/netbox/ui/panels.py similarity index 69% rename from netbox/netbox/ui/components.py rename to netbox/netbox/ui/panels.py index 156eb0304..15cad0a64 100644 --- a/netbox/netbox/ui/components.py +++ b/netbox/netbox/ui/panels.py @@ -1,5 +1,4 @@ from abc import ABC, ABCMeta, abstractmethod -from functools import cached_property from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ @@ -8,18 +7,21 @@ from netbox.ui import attrs from netbox.ui.attrs import Attr from utilities.string import title +__all__ = ( + 'NestedGroupObjectPanel', + 'ObjectPanel', + 'Panel', +) -class Component(ABC): + +class Panel(ABC): @abstractmethod - def render(self): + def render(self, obj): pass - def __str__(self): - return self.render() - -class ObjectDetailsPanelMeta(ABCMeta): +class ObjectPanelMeta(ABCMeta): def __new__(mcls, name, bases, namespace, **kwargs): declared = {} @@ -46,33 +48,29 @@ class ObjectDetailsPanelMeta(ABCMeta): return cls -class ObjectPanel(Component, metaclass=ObjectDetailsPanelMeta): - template_name = 'components/object_details_panel.html' +class ObjectPanel(Panel, metaclass=ObjectPanelMeta): + template_name = 'ui/panels/object.html' - def __init__(self, obj, title=None): - self.object = obj - self.title = title or obj._meta.verbose_name + def __init__(self, title=None): + self.title = title - @cached_property - def attributes(self): + def get_attributes(self, obj): return [ { 'label': attr.label or title(name), - 'value': attr.render(self.object, {'name': name}), + 'value': attr.render(obj, {'name': name}), } for name, attr in self._attrs.items() ] - def render(self): + def render(self, context): + obj = context.get('object') return render_to_string(self.template_name, { 'title': self.title, - 'attrs': self.attributes, + 'attrs': self.get_attributes(obj), }) - def __str__(self): - return self.render() - -class NestedGroupObjectPanel(ObjectPanel, metaclass=ObjectDetailsPanelMeta): +class NestedGroupObjectPanel(ObjectPanel, metaclass=ObjectPanelMeta): name = attrs.TextAttr('name', label=_('Name')) description = attrs.TextAttr('description', label=_('Description')) parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True) diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 897191592..eb1a4d3a9 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -47,6 +47,7 @@ class ObjectView(ActionsMixin, BaseObjectView): tab: A ViewTab instance for the view actions: An iterable of ObjectAction subclasses (see ActionsMixin) """ + layout = None tab = None actions = (CloneObject, EditObject, DeleteObject) @@ -58,6 +59,9 @@ class ObjectView(ActionsMixin, BaseObjectView): Return self.template_name if defined. Otherwise, dynamically resolve the template name using the queryset model's `app_label` and `model_name`. """ + # TODO: Temporarily allow layout to override template_name + if self.layout is not None: + return 'generic/object.html' if self.template_name is not None: return self.template_name model_opts = self.queryset.model._meta @@ -81,6 +85,7 @@ class ObjectView(ActionsMixin, BaseObjectView): 'object': instance, 'actions': actions, 'tab': self.tab, + 'layout': self.layout, **self.get_extra_context(request, instance), }) diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index df95a4a42..a9783178a 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -122,7 +122,20 @@ Context: {% plugin_alerts object %} {% endblock alerts %} -{% block content %}{% endblock %} +{% block content %} + {# Render panel layout declared on view class #} + {% for row in layout.rows %} +
+ {% for column in row.columns %} +
+ {% for panel in column.panels %} + {% render_panel panel %} + {% endfor %} +
+ {% endfor %} +
+ {% endfor %} +{% endblock %} {% block modals %} {% include 'inc/htmx_modal.html' %} diff --git a/netbox/templates/components/attrs/address.html b/netbox/templates/ui/attrs/address.html similarity index 100% rename from netbox/templates/components/attrs/address.html rename to netbox/templates/ui/attrs/address.html diff --git a/netbox/templates/components/attrs/choice.html b/netbox/templates/ui/attrs/choice.html similarity index 100% rename from netbox/templates/components/attrs/choice.html rename to netbox/templates/ui/attrs/choice.html diff --git a/netbox/templates/components/attrs/gps_coordinates.html b/netbox/templates/ui/attrs/gps_coordinates.html similarity index 100% rename from netbox/templates/components/attrs/gps_coordinates.html rename to netbox/templates/ui/attrs/gps_coordinates.html diff --git a/netbox/templates/components/attrs/nested_object.html b/netbox/templates/ui/attrs/nested_object.html similarity index 100% rename from netbox/templates/components/attrs/nested_object.html rename to netbox/templates/ui/attrs/nested_object.html diff --git a/netbox/templates/components/attrs/object.html b/netbox/templates/ui/attrs/object.html similarity index 100% rename from netbox/templates/components/attrs/object.html rename to netbox/templates/ui/attrs/object.html diff --git a/netbox/templates/components/attrs/text.html b/netbox/templates/ui/attrs/text.html similarity index 100% rename from netbox/templates/components/attrs/text.html rename to netbox/templates/ui/attrs/text.html diff --git a/netbox/templates/components/attrs/timezone.html b/netbox/templates/ui/attrs/timezone.html similarity index 100% rename from netbox/templates/components/attrs/timezone.html rename to netbox/templates/ui/attrs/timezone.html diff --git a/netbox/templates/components/attrs/utilization.html b/netbox/templates/ui/attrs/utilization.html similarity index 100% rename from netbox/templates/components/attrs/utilization.html rename to netbox/templates/ui/attrs/utilization.html diff --git a/netbox/templates/components/object_details_panel.html b/netbox/templates/ui/panels/object.html similarity index 100% rename from netbox/templates/components/object_details_panel.html rename to netbox/templates/ui/panels/object.html diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py index 8a275f44b..92c68f052 100644 --- a/netbox/utilities/templatetags/builtins/tags.py +++ b/netbox/utilities/templatetags/builtins/tags.py @@ -3,6 +3,7 @@ from urllib.parse import urlparse, urlunparse, parse_qs, urlencode from django import template from django.templatetags.static import static +from django.utils.safestring import mark_safe from extras.choices import CustomFieldTypeChoices from utilities.querydict import dict_to_querydict @@ -179,3 +180,8 @@ def static_with_params(path, **params): # Reconstruct the URL with the new query string new_parsed = parsed._replace(query=new_query) return urlunparse(new_parsed) + + +@register.simple_tag(takes_context=True) +def render_panel(context, panel): + return mark_safe(panel.render(context)) From 77613b37b201381dc319c65b211899d92375127e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Oct 2025 14:38:33 -0400 Subject: [PATCH 048/125] Add panels for common inclusion templates --- netbox/dcim/views.py | 11 ++- netbox/netbox/ui/panels.py | 74 ++++++++++++++++++- netbox/templates/ui/panels/_base.html | 4 + netbox/templates/ui/panels/comments.html | 12 +++ netbox/templates/ui/panels/custom_fields.html | 31 ++++++++ .../ui/panels/image_attachments.html | 7 ++ netbox/templates/ui/panels/object.html | 7 +- .../templates/ui/panels/related_objects.html | 25 +++++++ netbox/templates/ui/panels/tags.html | 15 ++++ 9 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 netbox/templates/ui/panels/_base.html create mode 100644 netbox/templates/ui/panels/comments.html create mode 100644 netbox/templates/ui/panels/custom_fields.html create mode 100644 netbox/templates/ui/panels/image_attachments.html create mode 100644 netbox/templates/ui/panels/related_objects.html create mode 100644 netbox/templates/ui/panels/tags.html diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index d5382fa26..5b1923b08 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -18,6 +18,7 @@ from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from netbox.object_actions import * from netbox.ui import layout +from netbox.ui.panels import CommentsPanel, CustomFieldsPanel, ImageAttachmentsPanel, RelatedObjectsPanel, TagsPanel from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count @@ -468,14 +469,20 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView): layout = layout.Layout( layout.Row( layout.Column( - panels.SitePanel(_('Site')) + panels.SitePanel(_('Site')), + CustomFieldsPanel(), + TagsPanel(), + CommentsPanel(), + ), + layout.Column( + RelatedObjectsPanel(), + ImageAttachmentsPanel(), ), ) ) def get_extra_context(self, request, instance): return { - # 'site_panel': panels.SitePanel(instance, _('Site')), 'related_models': self.get_related_models( request, instance, diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index 15cad0a64..96061d42e 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -8,14 +8,23 @@ from netbox.ui.attrs import Attr from utilities.string import title __all__ = ( + 'CommentsPanel', + 'CustomFieldsPanel', + 'ImageAttachmentsPanel', 'NestedGroupObjectPanel', 'ObjectPanel', + 'RelatedObjectsPanel', 'Panel', + 'TagsPanel', ) class Panel(ABC): + def __init__(self, title=None): + if title is not None: + self.title = title + @abstractmethod def render(self, obj): pass @@ -51,9 +60,6 @@ class ObjectPanelMeta(ABCMeta): class ObjectPanel(Panel, metaclass=ObjectPanelMeta): template_name = 'ui/panels/object.html' - def __init__(self, title=None): - self.title = title - def get_attributes(self, obj): return [ { @@ -74,3 +80,65 @@ class NestedGroupObjectPanel(ObjectPanel, metaclass=ObjectPanelMeta): name = attrs.TextAttr('name', label=_('Name')) description = attrs.TextAttr('description', label=_('Description')) parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True) + + +class CustomFieldsPanel(Panel): + template_name = 'ui/panels/custom_fields.html' + title = _('Custom Fields') + + def render(self, context): + obj = context.get('object') + custom_fields = obj.get_custom_fields_by_group() + if not custom_fields: + return '' + return render_to_string(self.template_name, { + 'title': self.title, + 'custom_fields': custom_fields, + }) + + +class TagsPanel(Panel): + template_name = 'ui/panels/tags.html' + title = _('Tags') + + def render(self, context): + return render_to_string(self.template_name, { + 'title': self.title, + 'object': context.get('object'), + }) + + +class CommentsPanel(Panel): + template_name = 'ui/panels/comments.html' + title = _('Comments') + + def render(self, context): + obj = context.get('object') + return render_to_string(self.template_name, { + 'title': self.title, + 'comments': obj.comments, + }) + + +class RelatedObjectsPanel(Panel): + template_name = 'ui/panels/related_objects.html' + title = _('Related Objects') + + def render(self, context): + return render_to_string(self.template_name, { + 'title': self.title, + 'object': context.get('object'), + 'related_models': context.get('related_models'), + }) + + +class ImageAttachmentsPanel(Panel): + template_name = 'ui/panels/image_attachments.html' + title = _('Image Attachments') + + def render(self, context): + return render_to_string(self.template_name, { + 'title': self.title, + 'request': context.get('request'), + 'object': context.get('object'), + }) diff --git a/netbox/templates/ui/panels/_base.html b/netbox/templates/ui/panels/_base.html new file mode 100644 index 000000000..e1a6b4196 --- /dev/null +++ b/netbox/templates/ui/panels/_base.html @@ -0,0 +1,4 @@ +
+

{{ title }}

+ {% block panel_content %}{% endblock %} +
diff --git a/netbox/templates/ui/panels/comments.html b/netbox/templates/ui/panels/comments.html new file mode 100644 index 000000000..de32162ce --- /dev/null +++ b/netbox/templates/ui/panels/comments.html @@ -0,0 +1,12 @@ +{% extends "ui/panels/_base.html" %} +{% load i18n %} + +{% block panel_content %} +
+ {% if comments %} + {{ comments|markdown }} + {% else %} + {% trans "None" %} + {% endif %} +
+{% endblock panel_content %} diff --git a/netbox/templates/ui/panels/custom_fields.html b/netbox/templates/ui/panels/custom_fields.html new file mode 100644 index 000000000..d0b1c5686 --- /dev/null +++ b/netbox/templates/ui/panels/custom_fields.html @@ -0,0 +1,31 @@ +{% extends "ui/panels/_base.html" %} +{% load i18n %} + +{% block panel_content %} + + {% for group_name, fields in custom_fields.items %} + {% if group_name %} + + + + {% endif %} + {% for field, value in fields.items %} + + + + + {% endfor %} + {% endfor %} +
{{ group_name }}
{{ field }} + {% if field.description %} + + {% endif %} + + {% customfield_value field value %} +
+{% endblock panel_content %} diff --git a/netbox/templates/ui/panels/image_attachments.html b/netbox/templates/ui/panels/image_attachments.html new file mode 100644 index 000000000..0b6ecdf80 --- /dev/null +++ b/netbox/templates/ui/panels/image_attachments.html @@ -0,0 +1,7 @@ +{% extends "ui/panels/_base.html" %} +{% load i18n %} + +{# TODO: Add "attach an image" button in panel header #} +{% block panel_content %} + {% htmx_table 'extras:imageattachment_list' object_type_id=object|content_type_id object_id=object.pk %} +{% endblock panel_content %} diff --git a/netbox/templates/ui/panels/object.html b/netbox/templates/ui/panels/object.html index def52f76a..399a0081e 100644 --- a/netbox/templates/ui/panels/object.html +++ b/netbox/templates/ui/panels/object.html @@ -1,5 +1,6 @@ -
-

{{ title }}

+{% extends "ui/panels/_base.html" %} + +{% block panel_content %} {% for attr in attrs %} @@ -10,4 +11,4 @@ {% endfor %}
-
+{% endblock panel_content %} diff --git a/netbox/templates/ui/panels/related_objects.html b/netbox/templates/ui/panels/related_objects.html new file mode 100644 index 000000000..29d6dc6c4 --- /dev/null +++ b/netbox/templates/ui/panels/related_objects.html @@ -0,0 +1,25 @@ +{% extends "ui/panels/_base.html" %} +{% load helpers %} +{% load i18n %} + +{% block panel_content %} + +{% endblock panel_content %} diff --git a/netbox/templates/ui/panels/tags.html b/netbox/templates/ui/panels/tags.html new file mode 100644 index 000000000..d505dc48d --- /dev/null +++ b/netbox/templates/ui/panels/tags.html @@ -0,0 +1,15 @@ +{% extends "ui/panels/_base.html" %} +{% load helpers %} +{% load i18n %} + +{% block panel_content %} +
+ {% with url=object|validated_viewname:"list" %} + {% for tag in object.tags.all %} + {% tag tag url %} + {% empty %} + {% trans "No tags assigned" %} + {% endfor %} + {% endwith %} +
+{% endblock panel_content %} From 4d5f8e946090fe00b3eda503f81757b2647b7d52 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Oct 2025 14:50:21 -0400 Subject: [PATCH 049/125] Add PluginContentPanel --- netbox/dcim/views.py | 13 +++++++++++-- netbox/netbox/ui/panels.py | 13 +++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 5b1923b08..3d0672019 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -18,7 +18,9 @@ from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from netbox.object_actions import * from netbox.ui import layout -from netbox.ui.panels import CommentsPanel, CustomFieldsPanel, ImageAttachmentsPanel, RelatedObjectsPanel, TagsPanel +from netbox.ui.panels import ( + CommentsPanel, CustomFieldsPanel, ImageAttachmentsPanel, PluginContentPanel, RelatedObjectsPanel, TagsPanel, +) from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count @@ -473,12 +475,19 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView): CustomFieldsPanel(), TagsPanel(), CommentsPanel(), + PluginContentPanel('left_page'), ), layout.Column( RelatedObjectsPanel(), ImageAttachmentsPanel(), + PluginContentPanel('right_page'), ), - ) + ), + layout.Row( + layout.Column( + PluginContentPanel('full_width_page'), + ), + ), ) def get_extra_context(self, request, instance): diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index 96061d42e..3948f0de4 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -6,6 +6,7 @@ from django.utils.translation import gettext_lazy as _ from netbox.ui import attrs from netbox.ui.attrs import Attr from utilities.string import title +from utilities.templatetags.plugins import _get_registered_content __all__ = ( 'CommentsPanel', @@ -15,6 +16,7 @@ __all__ = ( 'ObjectPanel', 'RelatedObjectsPanel', 'Panel', + 'PluginContentPanel', 'TagsPanel', ) @@ -142,3 +144,14 @@ class ImageAttachmentsPanel(Panel): 'request': context.get('request'), 'object': context.get('object'), }) + + +class PluginContentPanel(Panel): + + def __init__(self, method, **kwargs): + super().__init__(**kwargs) + self.method = method + + def render(self, context): + obj = context.get('object') + return _get_registered_content(obj, self.method, context) From e9b15436c4da24c9634add01d715e59691a57430 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Oct 2025 16:27:26 -0400 Subject: [PATCH 050/125] Add EmbeddedTablePanel --- netbox/dcim/views.py | 18 +++++++++++++- netbox/netbox/ui/panels.py | 24 +++++++++++++++++++ .../templates/ui/panels/embedded_table.html | 5 ++++ 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 netbox/templates/ui/panels/embedded_table.html diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 3d0672019..09a085ff7 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.contrib import messages from django.contrib.contenttypes.models import ContentType from django.core.paginator import EmptyPage, PageNotAnInteger @@ -19,7 +20,8 @@ from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from netbox.object_actions import * from netbox.ui import layout from netbox.ui.panels import ( - CommentsPanel, CustomFieldsPanel, ImageAttachmentsPanel, PluginContentPanel, RelatedObjectsPanel, TagsPanel, + CommentsPanel, CustomFieldsPanel, EmbeddedTablePanel, ImageAttachmentsPanel, PluginContentPanel, + RelatedObjectsPanel, TagsPanel, ) from netbox.views import generic from utilities.forms import ConfirmationForm @@ -485,6 +487,20 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView): ), layout.Row( layout.Column( + EmbeddedTablePanel( + 'dcim:location_list', + url_params={'site_id': lambda x: x.pk}, + title=_('Locations') + ), + EmbeddedTablePanel( + 'dcim:device_list', + url_params={ + 'site_id': lambda x: x.pk, + 'rack_id': settings.FILTERS_NULL_CHOICE_VALUE, + 'parent_bay_id': settings.FILTERS_NULL_CHOICE_VALUE, + }, + title=_('Non-Racked Devices') + ), PluginContentPanel('full_width_page'), ), ), diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index 3948f0de4..a602eaa33 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -5,12 +5,14 @@ from django.utils.translation import gettext_lazy as _ from netbox.ui import attrs from netbox.ui.attrs import Attr +from utilities.querydict import dict_to_querydict from utilities.string import title from utilities.templatetags.plugins import _get_registered_content __all__ = ( 'CommentsPanel', 'CustomFieldsPanel', + 'EmbeddedTablePanel', 'ImageAttachmentsPanel', 'NestedGroupObjectPanel', 'ObjectPanel', @@ -146,6 +148,28 @@ class ImageAttachmentsPanel(Panel): }) +class EmbeddedTablePanel(Panel): + template_name = 'ui/panels/embedded_table.html' + title = None + + def __init__(self, viewname, url_params=None, **kwargs): + super().__init__(**kwargs) + self.viewname = viewname + self.url_params = url_params or {} + + def render(self, context): + obj = context.get('object') + url_params = { + k: v(obj) if callable(v) else v for k, v in self.url_params.items() + } + # url_params['return_url'] = return_url or context['request'].path + return render_to_string(self.template_name, { + 'title': self.title, + 'viewname': self.viewname, + 'url_params': dict_to_querydict(url_params), + }) + + class PluginContentPanel(Panel): def __init__(self, method, **kwargs): diff --git a/netbox/templates/ui/panels/embedded_table.html b/netbox/templates/ui/panels/embedded_table.html new file mode 100644 index 000000000..64579705f --- /dev/null +++ b/netbox/templates/ui/panels/embedded_table.html @@ -0,0 +1,5 @@ +{% extends "ui/panels/_base.html" %} + +{% block panel_content %} + {% include 'builtins/htmx_table.html' %} +{% endblock panel_content %} From da68503a195258d66755dbeab2ab07b6b9338391 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Oct 2025 16:47:26 -0400 Subject: [PATCH 051/125] Remove panels from get_extra_context() --- netbox/dcim/views.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 09a085ff7..c05588a0f 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -233,7 +233,6 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView): regions = instance.get_descendants(include_self=True) return { - 'region_panel': panels.NestedGroupObjectPanel(instance, _('Region')), 'related_models': self.get_related_models( request, regions, @@ -345,7 +344,6 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView): groups = instance.get_descendants(include_self=True) return { - 'sitegroup_panel': panels.NestedGroupObjectPanel(instance, _('Site Group')), 'related_models': self.get_related_models( request, groups, @@ -610,7 +608,6 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView): locations = instance.get_descendants(include_self=True) location_content_type = ContentType.objects.get_for_model(instance) return { - 'location_panel': panels.LocationPanel(instance, _('Location')), 'related_models': self.get_related_models( request, locations, @@ -907,7 +904,6 @@ class RackView(GetRelatedModelsMixin, generic.ObjectView): ]) return { - 'rack_panel': panels.RackPanel(instance, _('Rack')), 'related_models': self.get_related_models( request, instance, @@ -2272,8 +2268,6 @@ class DeviceView(generic.ObjectView): return { 'vc_members': vc_members, 'svg_extra': f'highlight=id:{instance.pk}', - 'device_panel': panels.DevicePanel(instance, _('Device')), - 'management_panel': panels.DeviceManagementPanel(instance, _('Management')), } From 37bea1e98ebf91a41ccf8d5492b2b4776255dcf6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Nov 2025 09:55:56 -0500 Subject: [PATCH 052/125] Introduce panel actions --- netbox/netbox/ui/actions.py | 56 +++++++++++++ netbox/netbox/ui/panels.py | 100 +++++++++++------------ netbox/templates/ui/panels/_base.html | 16 +++- netbox/templates/ui/panels/comments.html | 4 +- 4 files changed, 121 insertions(+), 55 deletions(-) create mode 100644 netbox/netbox/ui/actions.py diff --git a/netbox/netbox/ui/actions.py b/netbox/netbox/ui/actions.py new file mode 100644 index 000000000..0b21b9071 --- /dev/null +++ b/netbox/netbox/ui/actions.py @@ -0,0 +1,56 @@ +from urllib.parse import urlencode + +from django.apps import apps +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from utilities.permissions import get_permission_for_model +from utilities.views import get_viewname + +__all__ = ( + 'AddObject', + 'PanelAction', +) + + +class PanelAction: + label = None + button_class = 'primary' + button_icon = None + + def __init__(self, view_name, view_kwargs=None, url_params=None, permissions=None, label=None): + self.view_name = view_name + self.view_kwargs = view_kwargs + self.url_params = url_params or {} + self.permissions = permissions + if label is not None: + self.label = label + + def get_url(self, obj): + url = reverse(self.view_name, kwargs=self.view_kwargs or {}) + if self.url_params: + url_params = { + k: v(obj) if callable(v) else v for k, v in self.url_params.items() + } + url = f'{url}?{urlencode(url_params)}' + return url + + def get_context(self, obj): + return { + 'url': self.get_url(obj), + 'label': self.label, + 'button_class': self.button_class, + 'button_icon': self.button_icon, + } + + +class AddObject(PanelAction): + label = _('Add') + button_icon = 'plus-thick' + + def __init__(self, model, label=None, url_params=None): + app_label, model_name = model.split('.') + model = apps.get_model(app_label, model_name) + view_name = get_viewname(model, 'add') + super().__init__(view_name=view_name, label=label, url_params=url_params) + self.permissions = [get_permission_for_model(model, 'add')] diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index a602eaa33..195dcfd3c 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -1,9 +1,10 @@ -from abc import ABC, ABCMeta, abstractmethod +from abc import ABC, ABCMeta +from django.contrib.contenttypes.models import ContentType from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ -from netbox.ui import attrs +from netbox.ui import actions, attrs from netbox.ui.attrs import Attr from utilities.querydict import dict_to_querydict from utilities.string import title @@ -24,14 +25,28 @@ __all__ = ( class Panel(ABC): + template_name = None + title = None + actions = [] - def __init__(self, title=None): + def __init__(self, title=None, actions=None): if title is not None: self.title = title + if actions is not None: + self.actions = actions - @abstractmethod - def render(self, obj): - pass + def get_context(self, obj): + return {} + + def render(self, context): + obj = context.get('object') + return render_to_string(self.template_name, { + 'request': context.get('request'), + 'object': obj, + 'title': self.title, + 'actions': [action.get_context(obj) for action in self.actions], + **self.get_context(obj), + }) class ObjectPanelMeta(ABCMeta): @@ -64,20 +79,16 @@ class ObjectPanelMeta(ABCMeta): class ObjectPanel(Panel, metaclass=ObjectPanelMeta): template_name = 'ui/panels/object.html' - def get_attributes(self, obj): - return [ + def get_context(self, obj): + attrs = [ { 'label': attr.label or title(name), 'value': attr.render(obj, {'name': name}), } for name, attr in self._attrs.items() ] - - def render(self, context): - obj = context.get('object') - return render_to_string(self.template_name, { - 'title': self.title, - 'attrs': self.get_attributes(obj), - }) + return { + 'attrs': attrs, + } class NestedGroupObjectPanel(ObjectPanel, metaclass=ObjectPanelMeta): @@ -90,44 +101,27 @@ class CustomFieldsPanel(Panel): template_name = 'ui/panels/custom_fields.html' title = _('Custom Fields') - def render(self, context): - obj = context.get('object') - custom_fields = obj.get_custom_fields_by_group() - if not custom_fields: - return '' - return render_to_string(self.template_name, { - 'title': self.title, - 'custom_fields': custom_fields, - }) + def get_context(self, obj): + return { + 'custom_fields': obj.get_custom_fields_by_group(), + } class TagsPanel(Panel): template_name = 'ui/panels/tags.html' title = _('Tags') - def render(self, context): - return render_to_string(self.template_name, { - 'title': self.title, - 'object': context.get('object'), - }) - class CommentsPanel(Panel): template_name = 'ui/panels/comments.html' title = _('Comments') - def render(self, context): - obj = context.get('object') - return render_to_string(self.template_name, { - 'title': self.title, - 'comments': obj.comments, - }) - class RelatedObjectsPanel(Panel): template_name = 'ui/panels/related_objects.html' title = _('Related Objects') + # TODO: Handle related_models from context def render(self, context): return render_to_string(self.template_name, { 'title': self.title, @@ -139,35 +133,37 @@ class RelatedObjectsPanel(Panel): class ImageAttachmentsPanel(Panel): template_name = 'ui/panels/image_attachments.html' title = _('Image Attachments') - - def render(self, context): - return render_to_string(self.template_name, { - 'title': self.title, - 'request': context.get('request'), - 'object': context.get('object'), - }) + actions = [ + actions.AddObject( + 'extras.imageattachment', + url_params={ + 'object_type': lambda obj: ContentType.objects.get_for_model(obj).pk, + 'object_id': lambda obj: obj.pk, + 'return_url': lambda obj: obj.get_absolute_url(), + }, + label=_('Attach an image'), + ), + ] class EmbeddedTablePanel(Panel): template_name = 'ui/panels/embedded_table.html' title = None - def __init__(self, viewname, url_params=None, **kwargs): + def __init__(self, view_name, url_params=None, **kwargs): super().__init__(**kwargs) - self.viewname = viewname + self.view_name = view_name self.url_params = url_params or {} - def render(self, context): - obj = context.get('object') + def get_context(self, obj): url_params = { k: v(obj) if callable(v) else v for k, v in self.url_params.items() } # url_params['return_url'] = return_url or context['request'].path - return render_to_string(self.template_name, { - 'title': self.title, - 'viewname': self.viewname, + return { + 'viewname': self.view_name, 'url_params': dict_to_querydict(url_params), - }) + } class PluginContentPanel(Panel): diff --git a/netbox/templates/ui/panels/_base.html b/netbox/templates/ui/panels/_base.html index e1a6b4196..47ae689b7 100644 --- a/netbox/templates/ui/panels/_base.html +++ b/netbox/templates/ui/panels/_base.html @@ -1,4 +1,18 @@
-

{{ title }}

+

+ {{ title }} + {% if actions %} +
+ {% for action in actions %} + + {% if action.button_icon %} + + {% endif %} + {{ action.label }} + + {% endfor %} +
+ {% endif %} +

{% block panel_content %}{% endblock %}
diff --git a/netbox/templates/ui/panels/comments.html b/netbox/templates/ui/panels/comments.html index de32162ce..d5f07a8cc 100644 --- a/netbox/templates/ui/panels/comments.html +++ b/netbox/templates/ui/panels/comments.html @@ -3,8 +3,8 @@ {% block panel_content %}
- {% if comments %} - {{ comments|markdown }} + {% if object.comments %} + {{ object.comments|markdown }} {% else %} {% trans "None" %} {% endif %} From c39298821296b1a6108c5f1e64df92600a2ee912 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Nov 2025 10:30:13 -0500 Subject: [PATCH 053/125] Replace EmbeddedTablePanel with ObjectsTablePanel --- netbox/dcim/views.py | 27 +++++---- netbox/netbox/ui/actions.py | 5 ++ netbox/netbox/ui/panels.py | 55 +++++++++++-------- .../ui/panels/image_attachments.html | 7 --- netbox/templates/ui/panels/objects_table.html | 5 ++ 5 files changed, 58 insertions(+), 41 deletions(-) delete mode 100644 netbox/templates/ui/panels/image_attachments.html create mode 100644 netbox/templates/ui/panels/objects_table.html diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index c05588a0f..5e00a6553 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -18,9 +18,9 @@ from extras.views import ObjectConfigContextView, ObjectRenderConfigView from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from netbox.object_actions import * -from netbox.ui import layout +from netbox.ui import actions, layout from netbox.ui.panels import ( - CommentsPanel, CustomFieldsPanel, EmbeddedTablePanel, ImageAttachmentsPanel, PluginContentPanel, + CommentsPanel, CustomFieldsPanel, ImageAttachmentsPanel, ObjectsTablePanel, PluginContentPanel, RelatedObjectsPanel, TagsPanel, ) from netbox.views import generic @@ -485,19 +485,24 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView): ), layout.Row( layout.Column( - EmbeddedTablePanel( - 'dcim:location_list', - url_params={'site_id': lambda x: x.pk}, - title=_('Locations') + ObjectsTablePanel( + model='dcim.Location', + filters={'site_id': lambda obj: obj.pk}, + actions=[ + actions.AddObject('dcim.Location', url_params={'site': lambda obj: obj.pk}), + ], ), - EmbeddedTablePanel( - 'dcim:device_list', - url_params={ - 'site_id': lambda x: x.pk, + ObjectsTablePanel( + model='dcim.Device', + title=_('Non-Racked Devices'), + filters={ + 'site_id': lambda obj: obj.pk, 'rack_id': settings.FILTERS_NULL_CHOICE_VALUE, 'parent_bay_id': settings.FILTERS_NULL_CHOICE_VALUE, }, - title=_('Non-Racked Devices') + actions=[ + actions.AddObject('dcim.Device', url_params={'site': lambda obj: obj.pk}), + ], ), PluginContentPanel('full_width_page'), ), diff --git a/netbox/netbox/ui/actions.py b/netbox/netbox/ui/actions.py index 0b21b9071..79fd9a5d6 100644 --- a/netbox/netbox/ui/actions.py +++ b/netbox/netbox/ui/actions.py @@ -32,6 +32,8 @@ class PanelAction: url_params = { k: v(obj) if callable(v) else v for k, v in self.url_params.items() } + if 'return_url' not in url_params: + url_params['return_url'] = obj.get_absolute_url() url = f'{url}?{urlencode(url_params)}' return url @@ -49,8 +51,11 @@ class AddObject(PanelAction): button_icon = 'plus-thick' def __init__(self, model, label=None, url_params=None): + # Resolve the model class from its app.name label app_label, model_name = model.split('.') model = apps.get_model(app_label, model_name) view_name = get_viewname(model, 'add') super().__init__(view_name=view_name, label=label, url_params=url_params) + + # Require "add" permission on the model by default self.permissions = [get_permission_for_model(model, 'add')] diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index 195dcfd3c..2ff495c42 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -1,5 +1,6 @@ from abc import ABC, ABCMeta +from django.apps import apps from django.contrib.contenttypes.models import ContentType from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ @@ -9,14 +10,15 @@ from netbox.ui.attrs import Attr from utilities.querydict import dict_to_querydict from utilities.string import title from utilities.templatetags.plugins import _get_registered_content +from utilities.views import get_viewname __all__ = ( 'CommentsPanel', 'CustomFieldsPanel', - 'EmbeddedTablePanel', 'ImageAttachmentsPanel', 'NestedGroupObjectPanel', 'ObjectPanel', + 'ObjectsTablePanel', 'RelatedObjectsPanel', 'Panel', 'PluginContentPanel', @@ -130,9 +132,33 @@ class RelatedObjectsPanel(Panel): }) -class ImageAttachmentsPanel(Panel): - template_name = 'ui/panels/image_attachments.html' - title = _('Image Attachments') +class ObjectsTablePanel(Panel): + template_name = 'ui/panels/objects_table.html' + title = None + + def __init__(self, model, filters=None, **kwargs): + super().__init__(**kwargs) + + # Resolve the model class from its app.name label + app_label, model_name = model.split('.') + self.model = apps.get_model(app_label, model_name) + self.filters = filters or {} + if self.title is None: + self.title = title(self.model._meta.verbose_name_plural) + + def get_context(self, obj): + url_params = { + k: v(obj) if callable(v) else v for k, v in self.filters.items() + } + if 'return_url' not in url_params: + url_params['return_url'] = obj.get_absolute_url() + return { + 'viewname': get_viewname(self.model, 'list'), + 'url_params': dict_to_querydict(url_params), + } + + +class ImageAttachmentsPanel(ObjectsTablePanel): actions = [ actions.AddObject( 'extras.imageattachment', @@ -145,25 +171,8 @@ class ImageAttachmentsPanel(Panel): ), ] - -class EmbeddedTablePanel(Panel): - template_name = 'ui/panels/embedded_table.html' - title = None - - def __init__(self, view_name, url_params=None, **kwargs): - super().__init__(**kwargs) - self.view_name = view_name - self.url_params = url_params or {} - - def get_context(self, obj): - url_params = { - k: v(obj) if callable(v) else v for k, v in self.url_params.items() - } - # url_params['return_url'] = return_url or context['request'].path - return { - 'viewname': self.view_name, - 'url_params': dict_to_querydict(url_params), - } + def __init__(self, **kwargs): + super().__init__('extras.imageattachment', **kwargs) class PluginContentPanel(Panel): diff --git a/netbox/templates/ui/panels/image_attachments.html b/netbox/templates/ui/panels/image_attachments.html deleted file mode 100644 index 0b6ecdf80..000000000 --- a/netbox/templates/ui/panels/image_attachments.html +++ /dev/null @@ -1,7 +0,0 @@ -{% extends "ui/panels/_base.html" %} -{% load i18n %} - -{# TODO: Add "attach an image" button in panel header #} -{% block panel_content %} - {% htmx_table 'extras:imageattachment_list' object_type_id=object|content_type_id object_id=object.pk %} -{% endblock panel_content %} diff --git a/netbox/templates/ui/panels/objects_table.html b/netbox/templates/ui/panels/objects_table.html new file mode 100644 index 000000000..64579705f --- /dev/null +++ b/netbox/templates/ui/panels/objects_table.html @@ -0,0 +1,5 @@ +{% extends "ui/panels/_base.html" %} + +{% block panel_content %} + {% include 'builtins/htmx_table.html' %} +{% endblock panel_content %} From 21bb734dcb98c992b696eb5611c5e6cc895e9a31 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Nov 2025 11:51:49 -0500 Subject: [PATCH 054/125] Define layouts for regions, site groups, locations --- netbox/dcim/views.py | 113 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 5e00a6553..5e60f65a7 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -20,8 +20,8 @@ from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from netbox.object_actions import * from netbox.ui import actions, layout from netbox.ui.panels import ( - CommentsPanel, CustomFieldsPanel, ImageAttachmentsPanel, ObjectsTablePanel, PluginContentPanel, - RelatedObjectsPanel, TagsPanel, + CommentsPanel, CustomFieldsPanel, ImageAttachmentsPanel, NestedGroupObjectPanel, ObjectsTablePanel, + PluginContentPanel, RelatedObjectsPanel, TagsPanel, ) from netbox.views import generic from utilities.forms import ConfirmationForm @@ -228,6 +228,34 @@ class RegionListView(generic.ObjectListView): @register_model_view(Region) class RegionView(GetRelatedModelsMixin, generic.ObjectView): queryset = Region.objects.all() + layout = layout.Layout( + layout.Row( + layout.Column( + NestedGroupObjectPanel(), + TagsPanel(), + CustomFieldsPanel(), + CommentsPanel(), + PluginContentPanel('left_page'), + ), + layout.Column( + RelatedObjectsPanel(), + PluginContentPanel('right_page'), + ), + ), + layout.Row( + layout.Column( + ObjectsTablePanel( + model='dcim.Region', + title=_('Child Regions'), + filters={'parent_id': lambda obj: obj.pk}, + actions=[ + actions.AddObject('dcim.Region', url_params={'parent': lambda obj: obj.pk}), + ], + ), + PluginContentPanel('full_width_page'), + ), + ), + ) def get_extra_context(self, request, instance): regions = instance.get_descendants(include_self=True) @@ -339,6 +367,34 @@ class SiteGroupListView(generic.ObjectListView): @register_model_view(SiteGroup) class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView): queryset = SiteGroup.objects.all() + layout = layout.Layout( + layout.Row( + layout.Column( + NestedGroupObjectPanel(), + TagsPanel(), + CustomFieldsPanel(), + CommentsPanel(), + PluginContentPanel('left_page'), + ), + layout.Column( + RelatedObjectsPanel(), + PluginContentPanel('right_page'), + ), + ), + layout.Row( + layout.Column( + ObjectsTablePanel( + model='dcim.SiteGroup', + title=_('Child Groups'), + filters={'parent_id': lambda obj: obj.pk}, + actions=[ + actions.AddObject('dcim.Region', url_params={'parent': lambda obj: obj.pk}), + ], + ), + PluginContentPanel('full_width_page'), + ), + ), + ) def get_extra_context(self, request, instance): groups = instance.get_descendants(include_self=True) @@ -608,6 +664,59 @@ class LocationListView(generic.ObjectListView): @register_model_view(Location) class LocationView(GetRelatedModelsMixin, generic.ObjectView): queryset = Location.objects.all() + layout = layout.Layout( + layout.Row( + layout.Column( + panels.LocationPanel(), + TagsPanel(), + CustomFieldsPanel(), + CommentsPanel(), + PluginContentPanel('left_page'), + ), + layout.Column( + RelatedObjectsPanel(), + ImageAttachmentsPanel(), + PluginContentPanel('right_page'), + ), + ), + layout.Row( + layout.Column( + ObjectsTablePanel( + model='dcim.Location', + title=_('Child Locations'), + filters={'parent_id': lambda obj: obj.pk}, + actions=[ + actions.AddObject( + 'dcim.Location', + url_params={ + 'site': lambda obj: obj.site.pk if obj.site else None, + 'parent': lambda obj: obj.pk, + } + ), + ], + ), + ObjectsTablePanel( + model='dcim.Device', + title=_('Non-Racked Devices'), + filters={ + 'location_id': lambda obj: obj.pk, + 'rack_id': settings.FILTERS_NULL_CHOICE_VALUE, + 'parent_bay_id': settings.FILTERS_NULL_CHOICE_VALUE, + }, + actions=[ + actions.AddObject( + 'dcim.Device', + url_params={ + 'site': lambda obj: obj.site.pk if obj.site else None, + 'parent': lambda obj: obj.pk, + } + ), + ], + ), + PluginContentPanel('full_width_page'), + ), + ), + ) def get_extra_context(self, request, instance): locations = instance.get_descendants(include_self=True) From 17cffd786075f8ba007dbd10e42ec01ead16b14a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Nov 2025 13:33:39 -0500 Subject: [PATCH 055/125] Add rack role & type layouts --- netbox/dcim/ui/panels.py | 31 +++++++++++++ netbox/dcim/views.py | 44 +++++++++++++++++- netbox/netbox/ui/attrs.py | 64 ++++++++++++++++++++++++-- netbox/netbox/ui/panels.py | 8 +++- netbox/templates/ui/attrs/boolean.html | 1 + netbox/templates/ui/attrs/color.html | 1 + netbox/templates/ui/attrs/numeric.html | 12 +++++ 7 files changed, 155 insertions(+), 6 deletions(-) create mode 100644 netbox/templates/ui/attrs/boolean.html create mode 100644 netbox/templates/ui/attrs/color.html create mode 100644 netbox/templates/ui/attrs/numeric.html diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index 0ed917c55..4db5e958c 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -23,6 +23,26 @@ class LocationPanel(panels.NestedGroupObjectPanel): facility = attrs.TextAttr('facility', label=_('Facility')) +class RackDimensionsPanel(panels.ObjectPanel): + form_factor = attrs.ChoiceAttr('form_factor', label=_('Form factor')) + width = attrs.ChoiceAttr('width', label=_('Width')) + u_height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height')) + outer_width = attrs.NumericAttr('outer_width', unit_accessor='get_outer_unit_display', label=_('Outer width')) + outer_height = attrs.NumericAttr('outer_height', unit_accessor='get_outer_unit_display', label=_('Outer height')) + outer_depth = attrs.NumericAttr('outer_depth', unit_accessor='get_outer_unit_display', label=_('Outer depth')) + mounting_depth = attrs.TextAttr('mounting_depth', format_string='{}mm', label=_('Mounting depth')) + + +class RackNumberingPanel(panels.ObjectPanel): + starting_unit = attrs.TextAttr('starting_unit', label=_('Starting unit')) + desc_units = attrs.BooleanAttr('desc_units', label=_('Descending units')) + + +class RackWeightPanel(panels.ObjectPanel): + weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display', label=_('Weight')) + max_weight = attrs.NumericAttr('max_weight', unit_accessor='get_weight_unit_display', label=_('Maximum weight')) + + class RackPanel(panels.ObjectPanel): region = attrs.NestedObjectAttr('site.region', label=_('Region'), linkify=True) site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') @@ -40,6 +60,17 @@ class RackPanel(panels.ObjectPanel): power_utilization = attrs.UtilizationAttr('get_power_utilization', label=_('Power utilization')) +class RackRolePanel(panels.OrganizationalObjectPanel): + color = attrs.ColorAttr('color') + + +class RackTypePanel(panels.ObjectPanel): + manufacturer = attrs.ObjectAttr('manufacturer', label=_('Manufacturer'), linkify=True) + model = attrs.TextAttr('model', label=_('Model')) + description = attrs.TextAttr('description', label=_('Description')) + airflow = attrs.ChoiceAttr('airflow', label=_('Airflow')) + + class DevicePanel(panels.ObjectPanel): region = attrs.NestedObjectAttr('site.region', label=_('Region'), linkify=True) site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 5e60f65a7..e825777f6 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -527,7 +527,7 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView): layout = layout.Layout( layout.Row( layout.Column( - panels.SitePanel(_('Site')), + panels.SitePanel(), CustomFieldsPanel(), TagsPanel(), CommentsPanel(), @@ -817,6 +817,25 @@ class RackRoleListView(generic.ObjectListView): @register_model_view(RackRole) class RackRoleView(GetRelatedModelsMixin, generic.ObjectView): queryset = RackRole.objects.all() + layout = layout.Layout( + layout.Row( + layout.Column( + panels.RackRolePanel(), + TagsPanel(), + PluginContentPanel('left_page'), + ), + layout.Column( + RelatedObjectsPanel(), + CustomFieldsPanel(), + PluginContentPanel('right_page'), + ), + ), + layout.Row( + layout.Column( + PluginContentPanel('full_width_page'), + ), + ), + ) def get_extra_context(self, request, instance): return { @@ -884,6 +903,29 @@ class RackTypeListView(generic.ObjectListView): @register_model_view(RackType) class RackTypeView(GetRelatedModelsMixin, generic.ObjectView): queryset = RackType.objects.all() + layout = layout.Layout( + layout.Row( + layout.Column( + panels.RackTypePanel(), + panels.RackDimensionsPanel(_('Dimensions')), + TagsPanel(), + CommentsPanel(), + PluginContentPanel('left_page'), + ), + layout.Column( + panels.RackNumberingPanel(_('Numbering')), + panels.RackWeightPanel(_('Weight')), + CustomFieldsPanel(), + RelatedObjectsPanel(), + PluginContentPanel('right_page'), + ), + ), + layout.Row( + layout.Column( + PluginContentPanel('full_width_page'), + ), + ), + ) def get_extra_context(self, request, instance): return { diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index 2e931d714..4df8f64e1 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -13,12 +13,14 @@ from netbox.config import get_config class Attr(ABC): template_name = None + label = None placeholder = mark_safe('') def __init__(self, accessor, label=None, template_name=None): self.accessor = accessor - self.label = label self.template_name = template_name or self.template_name + if label is not None: + self.label = label @abstractmethod def render(self, obj, context=None): @@ -37,9 +39,10 @@ class Attr(ABC): class TextAttr(Attr): template_name = 'ui/attrs/text.html' - def __init__(self, *args, style=None, copy_button=False, **kwargs): + def __init__(self, *args, style=None, format_string=None, copy_button=False, **kwargs): super().__init__(*args, **kwargs) self.style = style + self.format_string = format_string self.copy_button = copy_button def render(self, obj, context=None): @@ -47,6 +50,8 @@ class TextAttr(Attr): value = self._resolve_attr(obj, self.accessor) if value in (None, ''): return self.placeholder + if self.format_string: + value = self.format_string.format(value) return render_to_string(self.template_name, { **context, 'value': value, @@ -55,6 +60,28 @@ class TextAttr(Attr): }) +class NumericAttr(Attr): + template_name = 'ui/attrs/numeric.html' + + def __init__(self, *args, unit_accessor=None, copy_button=False, **kwargs): + super().__init__(*args, **kwargs) + self.unit_accessor = unit_accessor + self.copy_button = copy_button + + def render(self, obj, context=None): + context = context or {} + value = self._resolve_attr(obj, self.accessor) + if value in (None, ''): + return self.placeholder + unit = self._resolve_attr(obj, self.unit_accessor) if self.unit_accessor else None + return render_to_string(self.template_name, { + **context, + 'value': value, + 'unit': unit, + 'copy_button': self.copy_button, + }) + + class ChoiceAttr(Attr): template_name = 'ui/attrs/choice.html' @@ -77,6 +104,37 @@ class ChoiceAttr(Attr): }) +class BooleanAttr(Attr): + template_name = 'ui/attrs/boolean.html' + + def __init__(self, *args, display_false=True, **kwargs): + super().__init__(*args, **kwargs) + self.display_false = display_false + + def render(self, obj, context=None): + context = context or {} + value = self._resolve_attr(obj, self.accessor) + if value in (None, '') and not self.display_false: + return self.placeholder + return render_to_string(self.template_name, { + **context, + 'value': value, + }) + + +class ColorAttr(Attr): + template_name = 'ui/attrs/color.html' + label = _('Color') + + def render(self, obj, context=None): + context = context or {} + value = self._resolve_attr(obj, self.accessor) + return render_to_string(self.template_name, { + **context, + 'color': value, + }) + + class ObjectAttr(Attr): template_name = 'ui/attrs/object.html' @@ -149,9 +207,9 @@ class AddressAttr(Attr): class GPSCoordinatesAttr(Attr): template_name = 'ui/attrs/gps_coordinates.html' + label = _('GPS Coordinates') def __init__(self, latitude_attr='latitude', longitude_attr='longitude', map_url=True, **kwargs): - kwargs.setdefault('label', _('GPS Coordinates')) super().__init__(accessor=None, **kwargs) self.latitude_attr = latitude_attr self.longitude_attr = longitude_attr diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index 2ff495c42..d65c5ae2c 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -19,6 +19,7 @@ __all__ = ( 'NestedGroupObjectPanel', 'ObjectPanel', 'ObjectsTablePanel', + 'OrganizationalObjectPanel', 'RelatedObjectsPanel', 'Panel', 'PluginContentPanel', @@ -45,7 +46,7 @@ class Panel(ABC): return render_to_string(self.template_name, { 'request': context.get('request'), 'object': obj, - 'title': self.title, + 'title': self.title or title(obj._meta.verbose_name), 'actions': [action.get_context(obj) for action in self.actions], **self.get_context(obj), }) @@ -93,9 +94,12 @@ class ObjectPanel(Panel, metaclass=ObjectPanelMeta): } -class NestedGroupObjectPanel(ObjectPanel, metaclass=ObjectPanelMeta): +class OrganizationalObjectPanel(ObjectPanel, metaclass=ObjectPanelMeta): name = attrs.TextAttr('name', label=_('Name')) description = attrs.TextAttr('description', label=_('Description')) + + +class NestedGroupObjectPanel(OrganizationalObjectPanel, metaclass=ObjectPanelMeta): parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True) diff --git a/netbox/templates/ui/attrs/boolean.html b/netbox/templates/ui/attrs/boolean.html new file mode 100644 index 000000000..a724d687b --- /dev/null +++ b/netbox/templates/ui/attrs/boolean.html @@ -0,0 +1 @@ +{% checkmark object.desc_units %} diff --git a/netbox/templates/ui/attrs/color.html b/netbox/templates/ui/attrs/color.html new file mode 100644 index 000000000..29d11207a --- /dev/null +++ b/netbox/templates/ui/attrs/color.html @@ -0,0 +1 @@ +  diff --git a/netbox/templates/ui/attrs/numeric.html b/netbox/templates/ui/attrs/numeric.html new file mode 100644 index 000000000..5c54f2979 --- /dev/null +++ b/netbox/templates/ui/attrs/numeric.html @@ -0,0 +1,12 @@ +{% load i18n %} + + {{ value }} + {% if unit %} + {{ unit|lower }} + {% endif %} + +{% if copy_button %} + + + +{% endif %} From ed3dd019a763ee01abf03475b26ad09cba30f301 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Nov 2025 14:59:54 -0500 Subject: [PATCH 056/125] Move some panels to extras --- netbox/dcim/views.py | 4 ++-- netbox/extras/ui/panels.py | 42 +++++++++++++++++++++++++++++++++++++ netbox/netbox/ui/panels.py | 43 +++----------------------------------- 3 files changed, 47 insertions(+), 42 deletions(-) create mode 100644 netbox/extras/ui/panels.py diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index e825777f6..c0146165f 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -14,14 +14,14 @@ from django.views.generic import View from circuits.models import Circuit, CircuitTermination from dcim.ui import panels +from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel from extras.views import ObjectConfigContextView, ObjectRenderConfigView from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from netbox.object_actions import * from netbox.ui import actions, layout from netbox.ui.panels import ( - CommentsPanel, CustomFieldsPanel, ImageAttachmentsPanel, NestedGroupObjectPanel, ObjectsTablePanel, - PluginContentPanel, RelatedObjectsPanel, TagsPanel, + CommentsPanel, NestedGroupObjectPanel, ObjectsTablePanel, PluginContentPanel, RelatedObjectsPanel, ) from netbox.views import generic from utilities.forms import ConfirmationForm diff --git a/netbox/extras/ui/panels.py b/netbox/extras/ui/panels.py new file mode 100644 index 000000000..5d789f640 --- /dev/null +++ b/netbox/extras/ui/panels.py @@ -0,0 +1,42 @@ +from django.contrib.contenttypes.models import ContentType +from django.utils.translation import gettext_lazy as _ + +from netbox.ui import actions, panels + +__all__ = ( + 'CustomFieldsPanel', + 'ImageAttachmentsPanel', + 'TagsPanel', +) + + +class CustomFieldsPanel(panels.Panel): + template_name = 'ui/panels/custom_fields.html' + title = _('Custom Fields') + + def get_context(self, obj): + return { + 'custom_fields': obj.get_custom_fields_by_group(), + } + + +class ImageAttachmentsPanel(panels.ObjectsTablePanel): + actions = [ + actions.AddObject( + 'extras.imageattachment', + url_params={ + 'object_type': lambda obj: ContentType.objects.get_for_model(obj).pk, + 'object_id': lambda obj: obj.pk, + 'return_url': lambda obj: obj.get_absolute_url(), + }, + label=_('Attach an image'), + ), + ] + + def __init__(self, **kwargs): + super().__init__('extras.imageattachment', **kwargs) + + +class TagsPanel(panels.Panel): + template_name = 'ui/panels/tags.html' + title = _('Tags') diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index d65c5ae2c..d63558b90 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -1,12 +1,10 @@ from abc import ABC, ABCMeta from django.apps import apps -from django.contrib.contenttypes.models import ContentType from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ -from netbox.ui import actions, attrs -from netbox.ui.attrs import Attr +from netbox.ui import attrs from utilities.querydict import dict_to_querydict from utilities.string import title from utilities.templatetags.plugins import _get_registered_content @@ -14,8 +12,6 @@ from utilities.views import get_viewname __all__ = ( 'CommentsPanel', - 'CustomFieldsPanel', - 'ImageAttachmentsPanel', 'NestedGroupObjectPanel', 'ObjectPanel', 'ObjectsTablePanel', @@ -23,7 +19,6 @@ __all__ = ( 'RelatedObjectsPanel', 'Panel', 'PluginContentPanel', - 'TagsPanel', ) @@ -65,13 +60,13 @@ class ObjectPanelMeta(ABCMeta): # Add local declarations in the order they appear in the class body for key, attr in namespace.items(): - if isinstance(attr, Attr): + if isinstance(attr, attrs.Attr): declared[key] = attr namespace['_attrs'] = declared # Remove Attrs from the class namespace to keep things tidy - local_items = [key for key, attr in namespace.items() if isinstance(attr, Attr)] + local_items = [key for key, attr in namespace.items() if isinstance(attr, attrs.Attr)] for key in local_items: namespace.pop(key) @@ -103,21 +98,6 @@ class NestedGroupObjectPanel(OrganizationalObjectPanel, metaclass=ObjectPanelMet parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True) -class CustomFieldsPanel(Panel): - template_name = 'ui/panels/custom_fields.html' - title = _('Custom Fields') - - def get_context(self, obj): - return { - 'custom_fields': obj.get_custom_fields_by_group(), - } - - -class TagsPanel(Panel): - template_name = 'ui/panels/tags.html' - title = _('Tags') - - class CommentsPanel(Panel): template_name = 'ui/panels/comments.html' title = _('Comments') @@ -162,23 +142,6 @@ class ObjectsTablePanel(Panel): } -class ImageAttachmentsPanel(ObjectsTablePanel): - actions = [ - actions.AddObject( - 'extras.imageattachment', - url_params={ - 'object_type': lambda obj: ContentType.objects.get_for_model(obj).pk, - 'object_id': lambda obj: obj.pk, - 'return_url': lambda obj: obj.get_absolute_url(), - }, - label=_('Attach an image'), - ), - ] - - def __init__(self, **kwargs): - super().__init__('extras.imageattachment', **kwargs) - - class PluginContentPanel(Panel): def __init__(self, method, **kwargs): From 1cffbb21bb8e44ff67a38db5887cfbd244e57e88 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Nov 2025 15:04:29 -0500 Subject: [PATCH 057/125] Restore original object templates --- netbox/templates/dcim/device.html | 187 ++++++++++++++++++++++++++- netbox/templates/dcim/location.html | 39 +++++- netbox/templates/dcim/rack.html | 67 +++++++++- netbox/templates/dcim/region.html | 18 ++- netbox/templates/dcim/site.html | 96 +++++++++++++- netbox/templates/dcim/sitegroup.html | 18 ++- 6 files changed, 413 insertions(+), 12 deletions(-) diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 1ef8a406d..f8b8e95c2 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -11,7 +11,115 @@ {% block content %}
- {{ device_panel }} +
+

{% trans "Device" %}

+ + + + + + + + + + + + + + {% if object.virtual_chassis %} + + + + + {% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Region" %}{% nested_tree object.site.region %}
{% trans "Site" %}{{ object.site|linkify }}
{% trans "Location" %}{% nested_tree object.location %}
{% trans "Virtual Chassis" %}{{ object.virtual_chassis|linkify }}
{% trans "Rack" %} + {% if object.rack %} + {{ object.rack|linkify }} + + + + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Position" %} + {% if object.parent_bay %} + {% with object.parent_bay.device as parent %} + {{ parent|linkify }} / {{ object.parent_bay }} + {% if parent.position %} + (U{{ parent.position|floatformat }} / {{ parent.get_face_display }}) + {% endif %} + {% endwith %} + {% elif object.rack and object.position %} + U{{ object.position|floatformat }} / {{ object.get_face_display }} + {% elif object.rack and object.device_type.u_height %} + {% trans "Not racked" %} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "GPS Coordinates" %} + {% if object.latitude and object.longitude %} + {% if config.MAPS_URL %} + + {% endif %} + {{ object.latitude }}, {{ object.longitude }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Tenant" %} + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} +
{% trans "Device Type" %} + {{ object.device_type|linkify:"full_name" }} ({{ object.device_type.u_height|floatformat }}U) +
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Airflow" %} + {{ object.get_airflow_display|placeholder }} +
{% trans "Serial Number" %}{{ object.serial|placeholder }}
{% trans "Asset Tag" %}{{ object.asset_tag|placeholder }}
{% trans "Config Template" %}{{ object.config_template|linkify|placeholder }}
+
{% if vc_members %}

@@ -69,7 +177,82 @@ {% plugin_left_page object %}

- {{ management_panel }} +
+

{% trans "Management" %}

+ + + + + + + + + + + + + + + + + + + + + + + + + + {% if object.cluster %} + + + + + {% endif %} +
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Role" %}{{ object.role|linkify }}
{% trans "Platform" %}{{ object.platform|linkify|placeholder }}
{% trans "Primary IPv4" %} + {% if object.primary_ip4 %} + {{ object.primary_ip4.address.ip }} + {% if object.primary_ip4.nat_inside %} + ({% trans "NAT for" %} {{ object.primary_ip4.nat_inside.address.ip }}) + {% elif object.primary_ip4.nat_outside.exists %} + ({% trans "NAT" %}: {% for nat in object.primary_ip4.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) + {% endif %} + {% copy_content "primary_ip4" %} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Primary IPv6" %} + {% if object.primary_ip6 %} + {{ object.primary_ip6.address.ip }} + {% if object.primary_ip6.nat_inside %} + ({% trans "NAT for" %} {{ object.primary_ip6.nat_inside.address.ip }}) + {% elif object.primary_ip6.nat_outside.exists %} + ({% trans "NAT" %}: {% for nat in object.primary_ip6.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) + {% endif %} + {% copy_content "primary_ip6" %} + {% else %} + {{ ''|placeholder }} + {% endif %} +
Out-of-band IP + {% if object.oob_ip %} + {{ object.oob_ip.address.ip }} + {% if object.oob_ip.nat_inside %} + ({% trans "NAT for" %} {{ object.oob_ip.nat_inside.address.ip }}) + {% elif object.oob_ip.nat_outside.exists %} + ({% trans "NAT" %}: {% for nat in object.oob_ip.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) + {% endif %} + {% copy_content "oob_ip" %} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Cluster" %} + {% if object.cluster.group %} + {{ object.cluster.group|linkify }} / + {% endif %} + {{ object.cluster|linkify }} +
+
{% if object.powerports.exists and object.poweroutlets.exists %}

{% trans "Power Utilization" %}

diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index 861a2adef..dfd0c32b3 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -22,7 +22,44 @@ {% block content %}
- {{ location_panel }} +
+

{% trans "Location" %}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Site" %}{{ object.site|linkify }}
{% trans "Parent" %}{{ object.parent|linkify|placeholder }}
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Tenant" %} + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} +
{% trans "Facility" %}{{ object.facility|placeholder }}
+
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/comments.html' %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index f5eddcd3f..eec4d63a5 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -9,7 +9,72 @@ {% block content %}
- {{ rack_panel }} +
+

{% trans "Rack" %}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Region" %}{% nested_tree object.site.region %}
{% trans "Site" %}{{ object.site|linkify }}
{% trans "Location" %}{% nested_tree object.location %}
{% trans "Facility ID" %}{{ object.facility_id|placeholder }}
{% trans "Tenant" %} + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} +
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Rack Type" %}{{ object.rack_type|linkify:"full_name"|placeholder }}
{% trans "Role" %}{{ object.role|linkify|placeholder }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Serial Number" %}{{ object.serial|placeholder }}
{% trans "Asset Tag" %}{{ object.asset_tag|placeholder }}
{% trans "Airflow" %}{{ object.get_airflow_display|placeholder }}
{% trans "Space Utilization" %}{% utilization_graph object.get_utilization %}
{% trans "Power Utilization" %}{% utilization_graph object.get_power_utilization %}
+
{% include 'dcim/inc/panels/racktype_dimensions.html' %} {% include 'dcim/inc/panels/racktype_numbering.html' %}
diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html index 28f4b6127..f11868b0a 100644 --- a/netbox/templates/dcim/region.html +++ b/netbox/templates/dcim/region.html @@ -22,7 +22,23 @@ {% block content %}
- {{ region_panel }} +
+

{% trans "Region" %}

+ + + + + + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Parent" %}{{ object.parent|linkify|placeholder }}
+
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/comments.html' %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index f4e9a5d02..cf65961d9 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -24,16 +24,100 @@ {% block content %}
- {{ site_panel }} +
+

{% trans "Site" %}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Region" %} + {% nested_tree object.region %} +
{% trans "Group" %} + {% nested_tree object.group %} +
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Tenant" %} + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} +
{% trans "Facility" %}{{ object.facility|placeholder }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Time Zone" %} + {% if object.time_zone %} + {{ object.time_zone }} ({% trans "UTC" %} {{ object.time_zone|tzoffset }})
+ {% trans "Site time" %}: {% timezone object.time_zone %}{% now 'Y-m-d H:i' %}{% endtimezone %} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Physical Address" %} + {% if object.physical_address %} + {{ object.physical_address|linebreaksbr }} + {% if config.MAPS_URL %} + + {% trans "Map" %} + + {% endif %} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Shipping Address" %}{{ object.shipping_address|linebreaksbr|placeholder }}
{% trans "GPS Coordinates" %} + {% if object.latitude and object.longitude %} + {% if config.MAPS_URL %} + + {% endif %} + {{ object.latitude }}, {{ object.longitude }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
+
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% plugin_left_page object %} -
-
- {% include 'inc/panels/related_objects.html' with filter_name='site_id' %} - {% include 'inc/panels/image_attachments.html' %} - {% plugin_right_page object %} +
+
+ {% include 'inc/panels/related_objects.html' with filter_name='site_id' %} + {% include 'inc/panels/image_attachments.html' %} + {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html index 63e240dc6..dc9aca6f5 100644 --- a/netbox/templates/dcim/sitegroup.html +++ b/netbox/templates/dcim/sitegroup.html @@ -22,7 +22,23 @@ {% block content %}
- {{ sitegroup_panel }} +
+

{% trans "Site Group" %}

+ + + + + + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Parent" %}{{ object.parent|linkify|placeholder }}
+
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/comments.html' %} From 40b114c0bb3f6a76b88309b14d2115e7916f5e10 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Nov 2025 15:17:34 -0500 Subject: [PATCH 058/125] Add rack layout --- netbox/dcim/ui/panels.py | 13 ++++----- netbox/dcim/views.py | 27 ++++++++++++++++++- netbox/netbox/ui/panels.py | 8 ++++++ .../dcim/panels/rack_elevations.html | 22 +++++++++++++++ 4 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 netbox/templates/dcim/panels/rack_elevations.html diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index 4db5e958c..d26dfda45 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -38,16 +38,11 @@ class RackNumberingPanel(panels.ObjectPanel): desc_units = attrs.BooleanAttr('desc_units', label=_('Descending units')) -class RackWeightPanel(panels.ObjectPanel): - weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display', label=_('Weight')) - max_weight = attrs.NumericAttr('max_weight', unit_accessor='get_weight_unit_display', label=_('Maximum weight')) - - class RackPanel(panels.ObjectPanel): region = attrs.NestedObjectAttr('site.region', label=_('Region'), linkify=True) site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') location = attrs.NestedObjectAttr('location', label=_('Location'), linkify=True) - facility = attrs.TextAttr('facility', label=_('Facility')) + facility = attrs.TextAttr('facility', label=_('Facility ID')) tenant = attrs.ObjectAttr('tenant', label=_('Tenant'), linkify=True, grouped_by='group') status = attrs.ChoiceAttr('status', label=_('Status')) rack_type = attrs.ObjectAttr('rack_type', label=_('Rack type'), linkify=True, grouped_by='manufacturer') @@ -60,6 +55,12 @@ class RackPanel(panels.ObjectPanel): power_utilization = attrs.UtilizationAttr('get_power_utilization', label=_('Power utilization')) +class RackWeightPanel(panels.ObjectPanel): + weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display', label=_('Weight')) + max_weight = attrs.NumericAttr('max_weight', unit_accessor='get_weight_unit_display', label=_('Maximum weight')) + total_weight = attrs.NumericAttr('total_weight', unit_accessor='get_weight_unit_display', label=_('Total weight')) + + class RackRolePanel(panels.OrganizationalObjectPanel): color = attrs.ColorAttr('color') diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index c0146165f..facccb65a 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -21,7 +21,7 @@ from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from netbox.object_actions import * from netbox.ui import actions, layout from netbox.ui.panels import ( - CommentsPanel, NestedGroupObjectPanel, ObjectsTablePanel, PluginContentPanel, RelatedObjectsPanel, + CommentsPanel, NestedGroupObjectPanel, ObjectsTablePanel, PluginContentPanel, RelatedObjectsPanel, TemplatePanel, ) from netbox.views import generic from utilities.forms import ConfirmationForm @@ -1043,6 +1043,31 @@ class RackElevationListView(generic.ObjectListView): @register_model_view(Rack) class RackView(GetRelatedModelsMixin, generic.ObjectView): queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role') + layout = layout.Layout( + layout.Row( + layout.Column( + panels.RackPanel(), + panels.RackDimensionsPanel(_('Dimensions')), + panels.RackNumberingPanel(_('Numbering')), + panels.RackWeightPanel(_('Weight')), + CustomFieldsPanel(), + TagsPanel(), + CommentsPanel(), + ImageAttachmentsPanel(), + PluginContentPanel('left_page'), + ), + layout.Column( + TemplatePanel('dcim/panels/rack_elevations.html'), + RelatedObjectsPanel(), + PluginContentPanel('right_page'), + ), + ), + layout.Row( + layout.Column( + PluginContentPanel('full_width_page'), + ), + ), + ) def get_extra_context(self, request, instance): peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site) diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index d63558b90..328ef98df 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -19,6 +19,7 @@ __all__ = ( 'RelatedObjectsPanel', 'Panel', 'PluginContentPanel', + 'TemplatePanel', ) @@ -142,6 +143,13 @@ class ObjectsTablePanel(Panel): } +class TemplatePanel(Panel): + + def __init__(self, template_name, **kwargs): + super().__init__(**kwargs) + self.template_name = template_name + + class PluginContentPanel(Panel): def __init__(self, method, **kwargs): diff --git a/netbox/templates/dcim/panels/rack_elevations.html b/netbox/templates/dcim/panels/rack_elevations.html new file mode 100644 index 000000000..550f54f3b --- /dev/null +++ b/netbox/templates/dcim/panels/rack_elevations.html @@ -0,0 +1,22 @@ +{% load i18n %} +
+ +
+
+
+
+

{% trans "Front" %}

+ {% include 'dcim/inc/rack_elevation.html' with face='front' extra_params=svg_extra %} +
+
+
+
+

{% trans "Rear" %}

+ {% include 'dcim/inc/rack_elevation.html' with face='rear' extra_params=svg_extra %} +
+
+
From 17429c4257f92fbfeb0f102f4ac4771dc97157d1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Nov 2025 15:56:45 -0500 Subject: [PATCH 059/125] Clean up obsolete code --- netbox/netbox/ui/layout.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/netbox/netbox/ui/layout.py b/netbox/netbox/ui/layout.py index 1ff362e32..a314a597b 100644 --- a/netbox/netbox/ui/layout.py +++ b/netbox/netbox/ui/layout.py @@ -15,12 +15,8 @@ class Layout: raise TypeError(f"Row {i} must be a Row instance, not {type(row)}.") self.rows = rows - def render(self, context): - return ''.join([row.render(context) for row in self.rows]) - class Row: - template_name = 'ui/layout/row.html' def __init__(self, *columns): for i, column in enumerate(columns): @@ -28,9 +24,6 @@ class Row: raise TypeError(f"Column {i} must be a Column instance, not {type(column)}.") self.columns = columns - def render(self, context): - return ''.join([column.render(context) for column in self.columns]) - class Column: @@ -39,6 +32,3 @@ class Column: if not isinstance(panel, Panel): raise TypeError(f"Panel {i} must be an instance of a Panel, not {type(panel)}.") self.panels = panels - - def render(self, context): - return ''.join([panel.render(context) for panel in self.panels]) From c05106f9b266aa1cee47b054c69dce50b24ae0c2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Nov 2025 17:04:24 -0500 Subject: [PATCH 060/125] Limit object assignment to object panels --- netbox/dcim/views.py | 62 +++++++++++++++++++---------- netbox/extras/ui/panels.py | 16 ++++++-- netbox/netbox/ui/actions.py | 12 +++--- netbox/netbox/ui/panels.py | 78 ++++++++++++++++++++++++------------- 4 files changed, 110 insertions(+), 58 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index facccb65a..8fe7db406 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -247,9 +247,9 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView): ObjectsTablePanel( model='dcim.Region', title=_('Child Regions'), - filters={'parent_id': lambda obj: obj.pk}, + filters={'parent_id': lambda ctx: ctx['object'].pk}, actions=[ - actions.AddObject('dcim.Region', url_params={'parent': lambda obj: obj.pk}), + actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}), ], ), PluginContentPanel('full_width_page'), @@ -386,9 +386,9 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView): ObjectsTablePanel( model='dcim.SiteGroup', title=_('Child Groups'), - filters={'parent_id': lambda obj: obj.pk}, + filters={'parent_id': lambda ctx: ctx['object'].pk}, actions=[ - actions.AddObject('dcim.Region', url_params={'parent': lambda obj: obj.pk}), + actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}), ], ), PluginContentPanel('full_width_page'), @@ -543,21 +543,21 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView): layout.Column( ObjectsTablePanel( model='dcim.Location', - filters={'site_id': lambda obj: obj.pk}, + filters={'site_id': lambda ctx: ctx['object'].pk}, actions=[ - actions.AddObject('dcim.Location', url_params={'site': lambda obj: obj.pk}), + actions.AddObject('dcim.Location', url_params={'site': lambda ctx: ctx['object'].pk}), ], ), ObjectsTablePanel( model='dcim.Device', title=_('Non-Racked Devices'), filters={ - 'site_id': lambda obj: obj.pk, + 'site_id': lambda ctx: ctx['object'].pk, 'rack_id': settings.FILTERS_NULL_CHOICE_VALUE, 'parent_bay_id': settings.FILTERS_NULL_CHOICE_VALUE, }, actions=[ - actions.AddObject('dcim.Device', url_params={'site': lambda obj: obj.pk}), + actions.AddObject('dcim.Device', url_params={'site': lambda ctx: ctx['object'].pk}), ], ), PluginContentPanel('full_width_page'), @@ -684,13 +684,13 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView): ObjectsTablePanel( model='dcim.Location', title=_('Child Locations'), - filters={'parent_id': lambda obj: obj.pk}, + filters={'parent_id': lambda ctx: ctx['object'].pk}, actions=[ actions.AddObject( 'dcim.Location', url_params={ - 'site': lambda obj: obj.site.pk if obj.site else None, - 'parent': lambda obj: obj.pk, + 'site': lambda ctx: ctx['object'].site_id, + 'parent': lambda ctx: ctx['object'].pk, } ), ], @@ -699,7 +699,7 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView): model='dcim.Device', title=_('Non-Racked Devices'), filters={ - 'location_id': lambda obj: obj.pk, + 'location_id': lambda ctx: ctx['object'].pk, 'rack_id': settings.FILTERS_NULL_CHOICE_VALUE, 'parent_bay_id': settings.FILTERS_NULL_CHOICE_VALUE, }, @@ -707,8 +707,8 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView): actions.AddObject( 'dcim.Device', url_params={ - 'site': lambda obj: obj.site.pk if obj.site else None, - 'parent': lambda obj: obj.pk, + 'site': lambda ctx: ctx['object'].site_id, + 'parent': lambda ctx: ctx['object'].pk, } ), ], @@ -907,14 +907,14 @@ class RackTypeView(GetRelatedModelsMixin, generic.ObjectView): layout.Row( layout.Column( panels.RackTypePanel(), - panels.RackDimensionsPanel(_('Dimensions')), + panels.RackDimensionsPanel(title=_('Dimensions')), TagsPanel(), CommentsPanel(), PluginContentPanel('left_page'), ), layout.Column( - panels.RackNumberingPanel(_('Numbering')), - panels.RackWeightPanel(_('Weight')), + panels.RackNumberingPanel(title=_('Numbering')), + panels.RackWeightPanel(title=_('Weight'), exclude=['total_weight']), CustomFieldsPanel(), RelatedObjectsPanel(), PluginContentPanel('right_page'), @@ -1047,9 +1047,9 @@ class RackView(GetRelatedModelsMixin, generic.ObjectView): layout.Row( layout.Column( panels.RackPanel(), - panels.RackDimensionsPanel(_('Dimensions')), - panels.RackNumberingPanel(_('Numbering')), - panels.RackWeightPanel(_('Weight')), + panels.RackDimensionsPanel(title=_('Dimensions')), + panels.RackNumberingPanel(title=_('Numbering')), + panels.RackWeightPanel(title=_('Weight')), CustomFieldsPanel(), TagsPanel(), CommentsPanel(), @@ -1199,6 +1199,28 @@ class RackReservationListView(generic.ObjectListView): @register_model_view(RackReservation) class RackReservationView(generic.ObjectView): queryset = RackReservation.objects.all() + layout = layout.Layout( + layout.Row( + layout.Column( + panels.RackPanel(accessor='rack', only=['region', 'site', 'location']), + CustomFieldsPanel(), + TagsPanel(), + CommentsPanel(), + ImageAttachmentsPanel(), + PluginContentPanel('left_page'), + ), + layout.Column( + TemplatePanel('dcim/panels/rack_elevations.html'), + RelatedObjectsPanel(), + PluginContentPanel('right_page'), + ), + ), + layout.Row( + layout.Column( + PluginContentPanel('full_width_page'), + ), + ), + ) @register_model_view(RackReservation, 'add', detail=False) diff --git a/netbox/extras/ui/panels.py b/netbox/extras/ui/panels.py index 5d789f640..991a4aa3d 100644 --- a/netbox/extras/ui/panels.py +++ b/netbox/extras/ui/panels.py @@ -14,8 +14,10 @@ class CustomFieldsPanel(panels.Panel): template_name = 'ui/panels/custom_fields.html' title = _('Custom Fields') - def get_context(self, obj): + def get_context(self, context): + obj = context['object'] return { + **super().get_context(context), 'custom_fields': obj.get_custom_fields_by_group(), } @@ -25,9 +27,9 @@ class ImageAttachmentsPanel(panels.ObjectsTablePanel): actions.AddObject( 'extras.imageattachment', url_params={ - 'object_type': lambda obj: ContentType.objects.get_for_model(obj).pk, - 'object_id': lambda obj: obj.pk, - 'return_url': lambda obj: obj.get_absolute_url(), + 'object_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk, + 'object_id': lambda ctx: ctx['object'].pk, + 'return_url': lambda ctx: ctx['object'].get_absolute_url(), }, label=_('Attach an image'), ), @@ -40,3 +42,9 @@ class ImageAttachmentsPanel(panels.ObjectsTablePanel): class TagsPanel(panels.Panel): template_name = 'ui/panels/tags.html' title = _('Tags') + + def get_context(self, context): + return { + **super().get_context(context), + 'object': context['object'], + } diff --git a/netbox/netbox/ui/actions.py b/netbox/netbox/ui/actions.py index 79fd9a5d6..10be487c8 100644 --- a/netbox/netbox/ui/actions.py +++ b/netbox/netbox/ui/actions.py @@ -26,20 +26,20 @@ class PanelAction: if label is not None: self.label = label - def get_url(self, obj): + def get_url(self, context): url = reverse(self.view_name, kwargs=self.view_kwargs or {}) if self.url_params: url_params = { - k: v(obj) if callable(v) else v for k, v in self.url_params.items() + k: v(context) if callable(v) else v for k, v in self.url_params.items() } - if 'return_url' not in url_params: - url_params['return_url'] = obj.get_absolute_url() + if 'return_url' not in url_params and 'object' in context: + url_params['return_url'] = context['object'].get_absolute_url() url = f'{url}?{urlencode(url_params)}' return url - def get_context(self, obj): + def get_context(self, context): return { - 'url': self.get_url(obj), + 'url': self.get_url(context), 'label': self.label, 'button_class': self.button_class, 'button_icon': self.button_icon, diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index 328ef98df..05eae3e36 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -34,18 +34,15 @@ class Panel(ABC): if actions is not None: self.actions = actions - def get_context(self, obj): - return {} + def get_context(self, context): + return { + 'request': context.get('request'), + 'title': self.title, + 'actions': [action.get_context(context) for action in self.actions], + } def render(self, context): - obj = context.get('object') - return render_to_string(self.template_name, { - 'request': context.get('request'), - 'object': obj, - 'title': self.title or title(obj._meta.verbose_name), - 'actions': [action.get_context(obj) for action in self.actions], - **self.get_context(obj), - }) + return render_to_string(self.template_name, self.get_context(context)) class ObjectPanelMeta(ABCMeta): @@ -76,17 +73,39 @@ class ObjectPanelMeta(ABCMeta): class ObjectPanel(Panel, metaclass=ObjectPanelMeta): + accessor = None template_name = 'ui/panels/object.html' - def get_context(self, obj): - attrs = [ - { - 'label': attr.label or title(name), - 'value': attr.render(obj, {'name': name}), - } for name, attr in self._attrs.items() - ] + def __init__(self, accessor=None, only=None, exclude=None, **kwargs): + super().__init__(**kwargs) + if accessor is not None: + self.accessor = accessor + + # Set included/excluded attributes + if only is not None and exclude is not None: + raise ValueError("attrs and exclude cannot both be specified.") + self.only = only or [] + self.exclude = exclude or [] + + def get_context(self, context): + # Determine which attributes to display in the panel based on only/exclude args + attr_names = set(self._attrs.keys()) + if self.only: + attr_names &= set(self.only) + elif self.exclude: + attr_names -= set(self.exclude) + + obj = getattr(context['object'], self.accessor) if self.accessor else context['object'] + return { - 'attrs': attrs, + **super().get_context(context), + 'object': obj, + 'attrs': [ + { + 'label': attr.label or title(name), + 'value': attr.render(obj, {'name': name}), + } for name, attr in self._attrs.items() if name in attr_names + ], } @@ -108,13 +127,11 @@ class RelatedObjectsPanel(Panel): template_name = 'ui/panels/related_objects.html' title = _('Related Objects') - # TODO: Handle related_models from context - def render(self, context): - return render_to_string(self.template_name, { - 'title': self.title, - 'object': context.get('object'), + def get_context(self, context): + return { + **super().get_context(context), 'related_models': context.get('related_models'), - }) + } class ObjectsTablePanel(Panel): @@ -131,13 +148,14 @@ class ObjectsTablePanel(Panel): if self.title is None: self.title = title(self.model._meta.verbose_name_plural) - def get_context(self, obj): + def get_context(self, context): url_params = { - k: v(obj) if callable(v) else v for k, v in self.filters.items() + k: v(context) if callable(v) else v for k, v in self.filters.items() } - if 'return_url' not in url_params: - url_params['return_url'] = obj.get_absolute_url() + if 'return_url' not in url_params and 'object' in context: + url_params['return_url'] = context['object'].get_absolute_url() return { + **super().get_context(context), 'viewname': get_viewname(self.model, 'list'), 'url_params': dict_to_querydict(url_params), } @@ -149,6 +167,10 @@ class TemplatePanel(Panel): super().__init__(**kwargs) self.template_name = template_name + def render(self, context): + # Pass the entire context to the template + return render_to_string(self.template_name, context.flatten()) + class PluginContentPanel(Panel): From bcffc383bfec9124ebd3ad44830c61593e47c0dc Mon Sep 17 00:00:00 2001 From: bctiemann Date: Tue, 4 Nov 2025 10:01:22 -0500 Subject: [PATCH 061/125] Closes: #17936 - GFK serializer field (#20706) * Establish GFKSerializerField and replace get_* methods in circuits.py * Set read_only=True * Apply GFKSerializerField to all matching SerializerMethodFields * Use GFKSerializerField for ObjectChangeSerializer.changed_object and EventRuleSerializer.action_object --- contrib/openapi.json | 101 +++++++++--------- netbox/circuits/api/serializers_/circuits.py | 33 +----- .../core/api/serializers_/change_logging.py | 29 ++--- netbox/dcim/api/serializers_/cables.py | 11 +- .../api/serializers_/device_components.py | 13 +-- netbox/dcim/api/serializers_/devices.py | 12 +-- .../api/serializers_/devicetype_components.py | 13 +-- netbox/extras/api/serializers_/attachments.py | 11 +- netbox/extras/api/serializers_/bookmarks.py | 13 +-- netbox/extras/api/serializers_/events.py | 20 +--- netbox/extras/api/serializers_/journaling.py | 11 +- .../extras/api/serializers_/notifications.py | 21 +--- netbox/ipam/api/serializers_/fhrpgroups.py | 15 +-- netbox/ipam/api/serializers_/ip.py | 23 +--- netbox/ipam/api/serializers_/services.py | 14 +-- netbox/ipam/api/serializers_/vlans.py | 13 +-- netbox/netbox/api/gfk_fields.py | 18 ++++ netbox/tenancy/api/serializers_/contacts.py | 12 +-- .../api/serializers_/clusters.py | 13 +-- netbox/vpn/api/serializers_/l2vpn.py | 12 +-- netbox/vpn/api/serializers_/tunnels.py | 16 +-- .../wireless/api/serializers_/wirelesslans.py | 13 +-- 22 files changed, 119 insertions(+), 318 deletions(-) create mode 100644 netbox/netbox/api/gfk_fields.py diff --git a/contrib/openapi.json b/contrib/openapi.json index 965d000cd..a47a92f1b 100644 --- a/contrib/openapi.json +++ b/contrib/openapi.json @@ -206935,8 +206935,8 @@ "format": "int64" }, "object": { - "nullable": true, - "readOnly": true + "readOnly": true, + "nullable": true }, "user": { "$ref": "#/components/schemas/BriefUser" @@ -211700,8 +211700,8 @@ "readOnly": true }, "termination": { - "nullable": true, - "readOnly": true + "readOnly": true, + "nullable": true }, "created": { "type": "string", @@ -211972,8 +211972,8 @@ "nullable": true }, "termination": { - "nullable": true, - "readOnly": true + "readOnly": true, + "nullable": true }, "port_speed": { "type": "integer", @@ -212176,8 +212176,8 @@ "format": "int64" }, "member": { - "nullable": true, - "readOnly": true + "readOnly": true, + "nullable": true }, "priority": { "type": "object", @@ -212561,8 +212561,8 @@ "nullable": true }, "termination": { - "nullable": true, - "readOnly": true + "readOnly": true, + "nullable": true }, "port_speed": { "type": "integer", @@ -212978,8 +212978,8 @@ "nullable": true }, "scope": { - "nullable": true, - "readOnly": true + "readOnly": true, + "nullable": true }, "description": { "type": "string", @@ -215293,9 +215293,8 @@ "format": "int64" }, "object": { - "type": "object", - "additionalProperties": {}, - "readOnly": true + "readOnly": true, + "nullable": true }, "contact": { "$ref": "#/components/schemas/BriefContact" @@ -219029,9 +219028,8 @@ "nullable": true }, "action_object": { - "type": "object", - "additionalProperties": {}, - "readOnly": true + "readOnly": true, + "nullable": true }, "description": { "type": "string", @@ -219536,8 +219534,8 @@ "format": "int64" }, "interface": { - "nullable": true, - "readOnly": true + "readOnly": true, + "nullable": true }, "priority": { "type": "integer", @@ -221339,8 +221337,8 @@ "nullable": true }, "assigned_object": { - "nullable": true, - "readOnly": true + "readOnly": true, + "nullable": true }, "nat_inside": { "allOf": [ @@ -222501,8 +222499,8 @@ "format": "int64" }, "parent": { - "nullable": true, - "readOnly": true + "readOnly": true, + "nullable": true }, "name": { "type": "string", @@ -225660,8 +225658,8 @@ "nullable": true }, "component": { - "nullable": true, - "readOnly": true + "readOnly": true, + "nullable": true }, "tags": { "type": "array", @@ -226044,8 +226042,8 @@ "nullable": true }, "component": { - "nullable": true, - "readOnly": true + "readOnly": true, + "nullable": true }, "created": { "type": "string", @@ -226327,8 +226325,8 @@ "format": "int64" }, "assigned_object": { - "nullable": true, - "readOnly": true + "readOnly": true, + "nullable": true }, "created": { "type": "string", @@ -226777,8 +226775,8 @@ "format": "int64" }, "assigned_object": { - "nullable": true, - "readOnly": true + "readOnly": true, + "nullable": true }, "tags": { "type": "array", @@ -227155,8 +227153,8 @@ "nullable": true }, "assigned_object": { - "nullable": true, - "readOnly": true + "readOnly": true, + "nullable": true }, "description": { "type": "string", @@ -229542,8 +229540,8 @@ "format": "int64" }, "object": { - "nullable": true, - "readOnly": true + "readOnly": true, + "nullable": true }, "user": { "$ref": "#/components/schemas/BriefUser" @@ -229796,7 +229794,11 @@ "format": "int64" }, "changed_object": { - "nullable": true, + "readOnly": true, + "nullable": true + }, + "object_repr": { + "type": "string", "readOnly": true }, "message": { @@ -229821,6 +229823,7 @@ "display_url", "id", "message", + "object_repr", "postchange_data", "prechange_data", "request_id", @@ -248892,8 +248895,8 @@ "nullable": true }, "scope": { - "nullable": true, - "readOnly": true + "readOnly": true, + "nullable": true }, "tenant": { "allOf": [ @@ -252745,8 +252748,8 @@ "format": "int64" }, "parent": { - "nullable": true, - "readOnly": true + "readOnly": true, + "nullable": true }, "name": { "type": "string", @@ -253669,8 +253672,8 @@ "format": "int64" }, "object": { - "nullable": true, - "readOnly": true + "readOnly": true, + "nullable": true }, "user": { "$ref": "#/components/schemas/BriefUser" @@ -255193,8 +255196,8 @@ "nullable": true }, "termination": { - "nullable": true, - "readOnly": true + "readOnly": true, + "nullable": true }, "outside_ip": { "allOf": [ @@ -255671,8 +255674,8 @@ "nullable": true }, "scope": { - "nullable": true, - "readOnly": true + "readOnly": true, + "nullable": true }, "vid_ranges": { "type": "array", @@ -258528,8 +258531,8 @@ "nullable": true }, "scope": { - "nullable": true, - "readOnly": true + "readOnly": true, + "nullable": true }, "tenant": { "allOf": [ diff --git a/netbox/circuits/api/serializers_/circuits.py b/netbox/circuits/api/serializers_/circuits.py index bc84f66fa..79e50da28 100644 --- a/netbox/circuits/api/serializers_/circuits.py +++ b/netbox/circuits/api/serializers_/circuits.py @@ -1,5 +1,4 @@ from django.contrib.contenttypes.models import ContentType -from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from circuits.choices import CircuitPriorityChoices, CircuitStatusChoices, VirtualCircuitTerminationRoleChoices @@ -11,12 +10,12 @@ from circuits.models import ( from dcim.api.serializers_.device_components import InterfaceSerializer from dcim.api.serializers_.cables import CabledObjectSerializer from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField +from netbox.api.gfk_fields import GFKSerializerField from netbox.api.serializers import ( NetBoxModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, WritableNestedSerializer, ) from netbox.choices import DistanceUnitChoices from tenancy.api.serializers_.tenants import TenantSerializer -from utilities.api import get_serializer_for_model from .providers import ProviderAccountSerializer, ProviderNetworkSerializer, ProviderSerializer __all__ = ( @@ -55,7 +54,7 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer): default=None ) termination_id = serializers.IntegerField(allow_null=True, required=False, default=None) - termination = serializers.SerializerMethodField(read_only=True) + termination = GFKSerializerField(read_only=True) class Meta: model = CircuitTermination @@ -64,14 +63,6 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer): 'upstream_speed', 'xconnect_id', 'description', ] - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_termination(self, obj): - if obj.termination_id is None: - return None - serializer = get_serializer_for_model(obj.termination) - context = {'request': self.context['request']} - return serializer(obj.termination, nested=True, context=context).data - class CircuitGroupSerializer(OrganizationalModelSerializer): tenant = TenantSerializer(nested=True, required=False, allow_null=True) @@ -134,7 +125,7 @@ class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer default=None ) termination_id = serializers.IntegerField(allow_null=True, required=False, default=None) - termination = serializers.SerializerMethodField(read_only=True) + termination = GFKSerializerField(read_only=True) class Meta: model = CircuitTermination @@ -146,20 +137,12 @@ class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer ] brief_fields = ('id', 'url', 'display', 'circuit', 'term_side', 'description', 'cable', '_occupied') - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_termination(self, obj): - if obj.termination_id is None: - return None - serializer = get_serializer_for_model(obj.termination) - context = {'request': self.context['request']} - return serializer(obj.termination, nested=True, context=context).data - class CircuitGroupAssignmentSerializer(CircuitGroupAssignmentSerializer_): member_type = ContentTypeField( queryset=ContentType.objects.filter(CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS) ) - member = serializers.SerializerMethodField(read_only=True) + member = GFKSerializerField(read_only=True) class Meta: model = CircuitGroupAssignment @@ -169,14 +152,6 @@ class CircuitGroupAssignmentSerializer(CircuitGroupAssignmentSerializer_): ] brief_fields = ('id', 'url', 'display', 'group', 'member_type', 'member_id', 'member', 'priority') - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_member(self, obj): - if obj.member_id is None: - return None - serializer = get_serializer_for_model(obj.member) - context = {'request': self.context['request']} - return serializer(obj.member, nested=True, context=context).data - class VirtualCircuitTypeSerializer(OrganizationalModelSerializer): diff --git a/netbox/core/api/serializers_/change_logging.py b/netbox/core/api/serializers_/change_logging.py index 575a849d5..4431b6298 100644 --- a/netbox/core/api/serializers_/change_logging.py +++ b/netbox/core/api/serializers_/change_logging.py @@ -1,13 +1,11 @@ -from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from core.choices import * from core.models import ObjectChange -from netbox.api.exceptions import SerializerNotFound from netbox.api.fields import ChoiceField, ContentTypeField +from netbox.api.gfk_fields import GFKSerializerField from netbox.api.serializers import BaseModelSerializer from users.api.serializers_.users import UserSerializer -from utilities.api import get_serializer_for_model __all__ = ( 'ObjectChangeSerializer', @@ -26,7 +24,10 @@ class ObjectChangeSerializer(BaseModelSerializer): changed_object_type = ContentTypeField( read_only=True ) - changed_object = serializers.SerializerMethodField( + changed_object = GFKSerializerField( + read_only=True + ) + object_repr = serializers.CharField( read_only=True ) prechange_data = serializers.JSONField( @@ -44,22 +45,6 @@ class ObjectChangeSerializer(BaseModelSerializer): model = ObjectChange fields = [ 'id', 'url', 'display_url', 'display', 'time', 'user', 'user_name', 'request_id', 'action', - 'changed_object_type', 'changed_object_id', 'changed_object', 'message', 'prechange_data', - 'postchange_data', + 'changed_object_type', 'changed_object_id', 'changed_object', 'object_repr', 'message', + 'prechange_data', 'postchange_data', ] - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_changed_object(self, obj): - """ - Serialize a nested representation of the changed object. - """ - if obj.changed_object is None: - return None - - try: - serializer = get_serializer_for_model(obj.changed_object) - except SerializerNotFound: - return obj.object_repr - data = serializer(obj.changed_object, nested=True, context={'request': self.context['request']}).data - - return data diff --git a/netbox/dcim/api/serializers_/cables.py b/netbox/dcim/api/serializers_/cables.py index d72b0cbec..5f3017368 100644 --- a/netbox/dcim/api/serializers_/cables.py +++ b/netbox/dcim/api/serializers_/cables.py @@ -5,6 +5,7 @@ from rest_framework import serializers from dcim.choices import * from dcim.models import Cable, CablePath, CableTermination from netbox.api.fields import ChoiceField, ContentTypeField +from netbox.api.gfk_fields import GFKSerializerField from netbox.api.serializers import ( BaseModelSerializer, GenericObjectSerializer, NetBoxModelSerializer, PrimaryModelSerializer, ) @@ -53,9 +54,7 @@ class CableTerminationSerializer(NetBoxModelSerializer): termination_type = ContentTypeField( read_only=True, ) - termination = serializers.SerializerMethodField( - read_only=True, - ) + termination = GFKSerializerField(read_only=True) class Meta: model = CableTermination @@ -66,12 +65,6 @@ class CableTerminationSerializer(NetBoxModelSerializer): read_only_fields = fields brief_fields = ('id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id') - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_termination(self, obj): - serializer = get_serializer_for_model(obj.termination) - context = {'request': self.context['request']} - return serializer(obj.termination, nested=True, context=context).data - class CablePathSerializer(serializers.ModelSerializer): path = serializers.SerializerMethodField(read_only=True) diff --git a/netbox/dcim/api/serializers_/device_components.py b/netbox/dcim/api/serializers_/device_components.py index 468d75af9..b26cf9bbb 100644 --- a/netbox/dcim/api/serializers_/device_components.py +++ b/netbox/dcim/api/serializers_/device_components.py @@ -1,6 +1,5 @@ from django.utils.translation import gettext as _ from django.contrib.contenttypes.models import ContentType -from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from dcim.choices import * @@ -13,8 +12,8 @@ from ipam.api.serializers_.vlans import VLANSerializer, VLANTranslationPolicySer from ipam.api.serializers_.vrfs import VRFSerializer from ipam.models import VLAN from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField +from netbox.api.gfk_fields import GFKSerializerField from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer -from utilities.api import get_serializer_for_model from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer from wireless.api.serializers_.nested import NestedWirelessLinkSerializer from wireless.api.serializers_.wirelesslans import WirelessLANSerializer @@ -394,7 +393,7 @@ class InventoryItemSerializer(NetBoxModelSerializer): required=False, allow_null=True ) - component = serializers.SerializerMethodField(read_only=True, allow_null=True) + component = GFKSerializerField(read_only=True) _depth = serializers.IntegerField(source='level', read_only=True) status = ChoiceField(choices=InventoryItemStatusChoices, required=False) @@ -406,11 +405,3 @@ class InventoryItemSerializer(NetBoxModelSerializer): 'component_id', 'component', 'tags', 'custom_fields', 'created', 'last_updated', '_depth', ] brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', '_depth') - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_component(self, obj): - if obj.component is None: - return None - serializer = get_serializer_for_model(obj.component) - context = {'request': self.context['request']} - return serializer(obj.component, nested=True, context=context).data diff --git a/netbox/dcim/api/serializers_/devices.py b/netbox/dcim/api/serializers_/devices.py index 2a3e0cd42..c8de28fd8 100644 --- a/netbox/dcim/api/serializers_/devices.py +++ b/netbox/dcim/api/serializers_/devices.py @@ -11,9 +11,9 @@ from dcim.models import Device, DeviceBay, MACAddress, Module, VirtualDeviceCont from extras.api.serializers_.configtemplates import ConfigTemplateSerializer from ipam.api.serializers_.ip import IPAddressSerializer from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField +from netbox.api.gfk_fields import GFKSerializerField from netbox.api.serializers import PrimaryModelSerializer from tenancy.api.serializers_.tenants import TenantSerializer -from utilities.api import get_serializer_for_model from virtualization.api.serializers_.clusters import ClusterSerializer from .devicetypes import * from .nested import NestedDeviceBaySerializer, NestedDeviceSerializer, NestedModuleBaySerializer @@ -165,7 +165,7 @@ class MACAddressSerializer(PrimaryModelSerializer): required=False, allow_null=True ) - assigned_object = serializers.SerializerMethodField(read_only=True) + assigned_object = GFKSerializerField(read_only=True) class Meta: model = MACAddress @@ -174,11 +174,3 @@ class MACAddressSerializer(PrimaryModelSerializer): 'assigned_object', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'mac_address', 'description') - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_assigned_object(self, obj): - if obj.assigned_object is None: - return None - serializer = get_serializer_for_model(obj.assigned_object) - context = {'request': self.context['request']} - return serializer(obj.assigned_object, nested=True, context=context).data diff --git a/netbox/dcim/api/serializers_/devicetype_components.py b/netbox/dcim/api/serializers_/devicetype_components.py index 5f2d4b36a..b44565d65 100644 --- a/netbox/dcim/api/serializers_/devicetype_components.py +++ b/netbox/dcim/api/serializers_/devicetype_components.py @@ -1,5 +1,4 @@ from django.contrib.contenttypes.models import ContentType -from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from dcim.choices import * @@ -9,8 +8,8 @@ from dcim.models import ( InventoryItemTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, ) from netbox.api.fields import ChoiceField, ContentTypeField +from netbox.api.gfk_fields import GFKSerializerField from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer -from utilities.api import get_serializer_for_model from wireless.choices import * from .devicetypes import DeviceTypeSerializer, ModuleTypeSerializer from .manufacturers import ManufacturerSerializer @@ -313,7 +312,7 @@ class InventoryItemTemplateSerializer(ComponentTemplateSerializer): required=False, allow_null=True ) - component = serializers.SerializerMethodField(read_only=True, allow_null=True) + component = GFKSerializerField(read_only=True) _depth = serializers.IntegerField(source='level', read_only=True) class Meta: @@ -324,11 +323,3 @@ class InventoryItemTemplateSerializer(ComponentTemplateSerializer): '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'description', '_depth') - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_component(self, obj): - if obj.component is None: - return None - serializer = get_serializer_for_model(obj.component) - context = {'request': self.context['request']} - return serializer(obj.component, nested=True, context=context).data diff --git a/netbox/extras/api/serializers_/attachments.py b/netbox/extras/api/serializers_/attachments.py index 6507a12be..613825203 100644 --- a/netbox/extras/api/serializers_/attachments.py +++ b/netbox/extras/api/serializers_/attachments.py @@ -1,12 +1,11 @@ from django.core.exceptions import ObjectDoesNotExist -from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from core.models import ObjectType from extras.models import ImageAttachment from netbox.api.fields import ContentTypeField +from netbox.api.gfk_fields import GFKSerializerField from netbox.api.serializers import ValidatedModelSerializer -from utilities.api import get_serializer_for_model __all__ = ( 'ImageAttachmentSerializer', @@ -17,7 +16,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer): object_type = ContentTypeField( queryset=ObjectType.objects.all() ) - parent = serializers.SerializerMethodField(read_only=True) + parent = GFKSerializerField(read_only=True) image_width = serializers.IntegerField(read_only=True) image_height = serializers.IntegerField(read_only=True) @@ -43,9 +42,3 @@ class ImageAttachmentSerializer(ValidatedModelSerializer): super().validate(data) return data - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_parent(self, obj): - serializer = get_serializer_for_model(obj.parent) - context = {'request': self.context['request']} - return serializer(obj.parent, nested=True, context=context).data diff --git a/netbox/extras/api/serializers_/bookmarks.py b/netbox/extras/api/serializers_/bookmarks.py index a404d83c3..a1c56f5f0 100644 --- a/netbox/extras/api/serializers_/bookmarks.py +++ b/netbox/extras/api/serializers_/bookmarks.py @@ -1,12 +1,9 @@ -from drf_spectacular.utils import extend_schema_field -from rest_framework import serializers - from core.models import ObjectType from extras.models import Bookmark from netbox.api.fields import ContentTypeField +from netbox.api.gfk_fields import GFKSerializerField from netbox.api.serializers import ValidatedModelSerializer from users.api.serializers_.users import UserSerializer -from utilities.api import get_serializer_for_model __all__ = ( 'BookmarkSerializer', @@ -17,7 +14,7 @@ class BookmarkSerializer(ValidatedModelSerializer): object_type = ContentTypeField( queryset=ObjectType.objects.with_feature('bookmarks'), ) - object = serializers.SerializerMethodField(read_only=True) + object = GFKSerializerField(read_only=True) user = UserSerializer(nested=True) class Meta: @@ -26,9 +23,3 @@ class BookmarkSerializer(ValidatedModelSerializer): 'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created', ] brief_fields = ('id', 'url', 'display', 'object_id', 'object_type') - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_object(self, instance): - serializer = get_serializer_for_model(instance.object) - context = {'request': self.context['request']} - return serializer(instance.object, nested=True, context=context).data diff --git a/netbox/extras/api/serializers_/events.py b/netbox/extras/api/serializers_/events.py index ea33ef99d..0d72874e7 100644 --- a/netbox/extras/api/serializers_/events.py +++ b/netbox/extras/api/serializers_/events.py @@ -1,15 +1,10 @@ -from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import extend_schema_field -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 users.api.serializers_.mixins import OwnerMixin -from utilities.api import get_serializer_for_model -from .scripts import ScriptSerializer __all__ = ( 'EventRuleSerializer', @@ -30,7 +25,7 @@ class EventRuleSerializer(OwnerMixin, NetBoxModelSerializer): action_object_type = ContentTypeField( queryset=ObjectType.objects.with_feature('event_rules'), ) - action_object = serializers.SerializerMethodField(read_only=True) + action_object = GFKSerializerField(read_only=True) class Meta: model = EventRule @@ -41,17 +36,6 @@ class EventRuleSerializer(OwnerMixin, NetBoxModelSerializer): ] brief_fields = ('id', 'url', 'display', 'name', 'description') - @extend_schema_field(OpenApiTypes.OBJECT) - def get_action_object(self, instance): - context = {'request': self.context['request']} - # We need to manually instantiate the serializer for scripts - if instance.action_type == EventRuleActionChoices.SCRIPT: - script = instance.action_object - return ScriptSerializer(script, nested=True, context=context).data - else: - serializer = get_serializer_for_model(instance.action_object_type.model_class()) - return serializer(instance.action_object, nested=True, context=context).data - # # Webhooks diff --git a/netbox/extras/api/serializers_/journaling.py b/netbox/extras/api/serializers_/journaling.py index cba56fc32..03ec34451 100644 --- a/netbox/extras/api/serializers_/journaling.py +++ b/netbox/extras/api/serializers_/journaling.py @@ -1,14 +1,13 @@ from django.core.exceptions import ObjectDoesNotExist -from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from core.models import ObjectType from extras.choices import * from extras.models import JournalEntry from netbox.api.fields import ChoiceField, ContentTypeField +from netbox.api.gfk_fields import GFKSerializerField from netbox.api.serializers import NetBoxModelSerializer from users.models import User -from utilities.api import get_serializer_for_model __all__ = ( 'JournalEntrySerializer', @@ -19,7 +18,7 @@ class JournalEntrySerializer(NetBoxModelSerializer): assigned_object_type = ContentTypeField( queryset=ObjectType.objects.all() ) - assigned_object = serializers.SerializerMethodField(read_only=True) + assigned_object = GFKSerializerField(read_only=True) created_by = serializers.PrimaryKeyRelatedField( allow_null=True, queryset=User.objects.all(), @@ -51,9 +50,3 @@ class JournalEntrySerializer(NetBoxModelSerializer): ) return super().validate(data) - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_assigned_object(self, instance): - serializer = get_serializer_for_model(instance.assigned_object_type.model_class()) - context = {'request': self.context['request']} - return serializer(instance.assigned_object, nested=True, context=context).data diff --git a/netbox/extras/api/serializers_/notifications.py b/netbox/extras/api/serializers_/notifications.py index 9f0c7cff3..4c9d08169 100644 --- a/netbox/extras/api/serializers_/notifications.py +++ b/netbox/extras/api/serializers_/notifications.py @@ -1,13 +1,10 @@ -from drf_spectacular.utils import extend_schema_field -from rest_framework import serializers - from core.models import ObjectType from extras.models import Notification, NotificationGroup, Subscription from netbox.api.fields import ContentTypeField, SerializedPKRelatedField +from netbox.api.gfk_fields import GFKSerializerField from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer from users.api.serializers_.users import GroupSerializer, UserSerializer from users.models import Group, User -from utilities.api import get_serializer_for_model __all__ = ( 'NotificationSerializer', @@ -20,7 +17,7 @@ class NotificationSerializer(ValidatedModelSerializer): object_type = ContentTypeField( queryset=ObjectType.objects.with_feature('notifications'), ) - object = serializers.SerializerMethodField(read_only=True) + object = GFKSerializerField(read_only=True) user = UserSerializer(nested=True) class Meta: @@ -30,12 +27,6 @@ class NotificationSerializer(ValidatedModelSerializer): ] brief_fields = ('id', 'url', 'display', 'object_type', 'object_id', 'user', 'read', 'event_type') - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_object(self, instance): - serializer = get_serializer_for_model(instance.object) - context = {'request': self.context['request']} - return serializer(instance.object, nested=True, context=context).data - class NotificationGroupSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer): groups = SerializedPKRelatedField( @@ -65,7 +56,7 @@ class SubscriptionSerializer(ValidatedModelSerializer): object_type = ContentTypeField( queryset=ObjectType.objects.with_feature('notifications'), ) - object = serializers.SerializerMethodField(read_only=True) + object = GFKSerializerField(read_only=True) user = UserSerializer(nested=True) class Meta: @@ -74,9 +65,3 @@ class SubscriptionSerializer(ValidatedModelSerializer): 'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created', ] brief_fields = ('id', 'url', 'display', 'object_type', 'object_id', 'user') - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_object(self, instance): - serializer = get_serializer_for_model(instance.object) - context = {'request': self.context['request']} - return serializer(instance.object, nested=True, context=context).data diff --git a/netbox/ipam/api/serializers_/fhrpgroups.py b/netbox/ipam/api/serializers_/fhrpgroups.py index 82750f1ba..cf8770682 100644 --- a/netbox/ipam/api/serializers_/fhrpgroups.py +++ b/netbox/ipam/api/serializers_/fhrpgroups.py @@ -1,11 +1,8 @@ from django.contrib.contenttypes.models import ContentType -from drf_spectacular.utils import extend_schema_field -from rest_framework import serializers - from ipam.models import FHRPGroup, FHRPGroupAssignment from netbox.api.fields import ContentTypeField +from netbox.api.gfk_fields import GFKSerializerField from netbox.api.serializers import NetBoxModelSerializer, PrimaryModelSerializer -from utilities.api import get_serializer_for_model from .ip import IPAddressSerializer __all__ = ( @@ -31,7 +28,7 @@ class FHRPGroupAssignmentSerializer(NetBoxModelSerializer): interface_type = ContentTypeField( queryset=ContentType.objects.all() ) - interface = serializers.SerializerMethodField(read_only=True) + interface = GFKSerializerField(read_only=True) class Meta: model = FHRPGroupAssignment @@ -40,11 +37,3 @@ class FHRPGroupAssignmentSerializer(NetBoxModelSerializer): 'priority', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'priority') - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_interface(self, obj): - if obj.interface is None: - return None - serializer = get_serializer_for_model(obj.interface) - context = {'request': self.context['request']} - return serializer(obj.interface, nested=True, context=context).data diff --git a/netbox/ipam/api/serializers_/ip.py b/netbox/ipam/api/serializers_/ip.py index 7dd277479..51f23f88d 100644 --- a/netbox/ipam/api/serializers_/ip.py +++ b/netbox/ipam/api/serializers_/ip.py @@ -1,5 +1,4 @@ from django.contrib.contenttypes.models import ContentType -from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from dcim.constants import LOCATION_SCOPE_TYPES @@ -7,9 +6,9 @@ from ipam.choices import * from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS from ipam.models import Aggregate, IPAddress, IPRange, Prefix from netbox.api.fields import ChoiceField, ContentTypeField +from netbox.api.gfk_fields import GFKSerializerField from netbox.api.serializers import PrimaryModelSerializer from tenancy.api.serializers_.tenants import TenantSerializer -from utilities.api import get_serializer_for_model from .asns import RIRSerializer from .nested import NestedIPAddressSerializer from .roles import RoleSerializer @@ -55,7 +54,7 @@ class PrefixSerializer(PrimaryModelSerializer): default=None ) scope_id = serializers.IntegerField(allow_null=True, required=False, default=None) - scope = serializers.SerializerMethodField(read_only=True) + scope = GFKSerializerField(read_only=True) tenant = TenantSerializer(nested=True, required=False, allow_null=True) vlan = VLANSerializer(nested=True, required=False, allow_null=True) status = ChoiceField(choices=PrefixStatusChoices, required=False) @@ -73,14 +72,6 @@ class PrefixSerializer(PrimaryModelSerializer): ] brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'description', '_depth') - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_scope(self, obj): - if obj.scope_id is None: - return None - serializer = get_serializer_for_model(obj.scope) - context = {'request': self.context['request']} - return serializer(obj.scope, nested=True, context=context).data - class PrefixLengthSerializer(serializers.Serializer): @@ -168,7 +159,7 @@ class IPAddressSerializer(PrimaryModelSerializer): required=False, allow_null=True ) - assigned_object = serializers.SerializerMethodField(read_only=True) + assigned_object = GFKSerializerField(read_only=True) nat_inside = NestedIPAddressSerializer(required=False, allow_null=True) nat_outside = NestedIPAddressSerializer(many=True, read_only=True) @@ -181,14 +172,6 @@ class IPAddressSerializer(PrimaryModelSerializer): ] brief_fields = ('id', 'url', 'display', 'family', 'address', 'description') - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_assigned_object(self, obj): - if obj.assigned_object is None: - return None - serializer = get_serializer_for_model(obj.assigned_object) - context = {'request': self.context['request']} - return serializer(obj.assigned_object, nested=True, context=context).data - class AvailableIPSerializer(serializers.Serializer): """ diff --git a/netbox/ipam/api/serializers_/services.py b/netbox/ipam/api/serializers_/services.py index ad7c3e00b..824fc5738 100644 --- a/netbox/ipam/api/serializers_/services.py +++ b/netbox/ipam/api/serializers_/services.py @@ -1,13 +1,11 @@ from django.contrib.contenttypes.models import ContentType -from drf_spectacular.utils import extend_schema_field -from rest_framework import serializers from ipam.choices import * from ipam.constants import SERVICE_ASSIGNMENT_MODELS from ipam.models import IPAddress, Service, ServiceTemplate from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField +from netbox.api.gfk_fields import GFKSerializerField from netbox.api.serializers import PrimaryModelSerializer -from utilities.api import get_serializer_for_model from .ip import IPAddressSerializer __all__ = ( @@ -40,7 +38,7 @@ class ServiceSerializer(PrimaryModelSerializer): parent_object_type = ContentTypeField( queryset=ContentType.objects.filter(SERVICE_ASSIGNMENT_MODELS) ) - parent = serializers.SerializerMethodField(read_only=True) + parent = GFKSerializerField(read_only=True) class Meta: model = Service @@ -50,11 +48,3 @@ class ServiceSerializer(PrimaryModelSerializer): 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'name', 'protocol', 'ports', 'description') - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_parent(self, obj): - if obj.parent is None: - return None - serializer = get_serializer_for_model(obj.parent) - context = {'request': self.context['request']} - return serializer(obj.parent, nested=True, context=context).data diff --git a/netbox/ipam/api/serializers_/vlans.py b/netbox/ipam/api/serializers_/vlans.py index 7f2633e27..133adb12c 100644 --- a/netbox/ipam/api/serializers_/vlans.py +++ b/netbox/ipam/api/serializers_/vlans.py @@ -1,5 +1,4 @@ from django.contrib.contenttypes.models import ContentType -from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from dcim.api.serializers_.sites import SiteSerializer @@ -7,9 +6,9 @@ from ipam.choices import * from ipam.constants import VLANGROUP_SCOPE_TYPES from ipam.models import VLAN, VLANGroup, VLANTranslationPolicy, VLANTranslationRule from netbox.api.fields import ChoiceField, ContentTypeField, IntegerRangeSerializer, RelatedObjectCountField +from netbox.api.gfk_fields import GFKSerializerField from netbox.api.serializers import NetBoxModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer from tenancy.api.serializers_.tenants import TenantSerializer -from utilities.api import get_serializer_for_model from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer from .nested import NestedVLANSerializer from .roles import RoleSerializer @@ -34,7 +33,7 @@ class VLANGroupSerializer(OrganizationalModelSerializer): default=None ) scope_id = serializers.IntegerField(allow_null=True, required=False, default=None) - scope = serializers.SerializerMethodField(read_only=True) + scope = GFKSerializerField(read_only=True) vid_ranges = IntegerRangeSerializer(many=True, required=False) utilization = serializers.CharField(read_only=True) tenant = TenantSerializer(nested=True, required=False, allow_null=True) @@ -52,14 +51,6 @@ class VLANGroupSerializer(OrganizationalModelSerializer): brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'vlan_count') validators = [] - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_scope(self, obj): - if obj.scope_id is None: - return None - serializer = get_serializer_for_model(obj.scope) - context = {'request': self.context['request']} - return serializer(obj.scope, nested=True, context=context).data - class VLANSerializer(PrimaryModelSerializer): site = SiteSerializer(nested=True, required=False, allow_null=True) diff --git a/netbox/netbox/api/gfk_fields.py b/netbox/netbox/api/gfk_fields.py new file mode 100644 index 000000000..d2bbeac4c --- /dev/null +++ b/netbox/netbox/api/gfk_fields.py @@ -0,0 +1,18 @@ +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers +from utilities.api import get_serializer_for_model + +__all__ = ( + 'GFKSerializerField', +) + + +@extend_schema_field(serializers.JSONField(allow_null=True, read_only=True)) +class GFKSerializerField(serializers.Field): + + def to_representation(self, instance, **kwargs): + if instance is None: + return None + serializer = get_serializer_for_model(instance) + context = {'request': self.context['request']} + return serializer(instance, nested=True, context=context).data diff --git a/netbox/tenancy/api/serializers_/contacts.py b/netbox/tenancy/api/serializers_/contacts.py index 19c496139..718af4768 100644 --- a/netbox/tenancy/api/serializers_/contacts.py +++ b/netbox/tenancy/api/serializers_/contacts.py @@ -1,15 +1,13 @@ from django.contrib.auth.models import ContentType -from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField +from netbox.api.gfk_fields import GFKSerializerField from netbox.api.serializers import ( NestedGroupModelSerializer, NetBoxModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, ) from tenancy.choices import ContactPriorityChoices from tenancy.models import ContactAssignment, Contact, ContactGroup, ContactRole -from utilities.api import get_serializer_for_model from .nested import NestedContactGroupSerializer __all__ = ( @@ -66,7 +64,7 @@ class ContactAssignmentSerializer(NetBoxModelSerializer): object_type = ContentTypeField( queryset=ContentType.objects.all() ) - object = serializers.SerializerMethodField(read_only=True) + object = GFKSerializerField(read_only=True) contact = ContactSerializer(nested=True) role = ContactRoleSerializer(nested=True, required=False, allow_null=True) priority = ChoiceField(choices=ContactPriorityChoices, allow_blank=True, required=False, default=lambda: '') @@ -78,9 +76,3 @@ class ContactAssignmentSerializer(NetBoxModelSerializer): 'tags', 'custom_fields', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'contact', 'role', 'priority') - - @extend_schema_field(OpenApiTypes.OBJECT) - def get_object(self, instance): - serializer = get_serializer_for_model(instance.object_type.model_class()) - context = {'request': self.context['request']} - return serializer(instance.object, nested=True, context=context).data diff --git a/netbox/virtualization/api/serializers_/clusters.py b/netbox/virtualization/api/serializers_/clusters.py index a48af9ce0..e352460bf 100644 --- a/netbox/virtualization/api/serializers_/clusters.py +++ b/netbox/virtualization/api/serializers_/clusters.py @@ -1,13 +1,12 @@ from dcim.constants import LOCATION_SCOPE_TYPES from django.contrib.contenttypes.models import ContentType -from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField +from netbox.api.gfk_fields import GFKSerializerField from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer from tenancy.api.serializers_.tenants import TenantSerializer from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType -from utilities.api import get_serializer_for_model __all__ = ( 'ClusterGroupSerializer', @@ -58,7 +57,7 @@ class ClusterSerializer(PrimaryModelSerializer): default=None ) scope_id = serializers.IntegerField(allow_null=True, required=False, default=None) - scope = serializers.SerializerMethodField(read_only=True) + scope = GFKSerializerField(read_only=True) allocated_vcpus = serializers.DecimalField( read_only=True, max_digits=8, @@ -80,11 +79,3 @@ class ClusterSerializer(PrimaryModelSerializer): 'device_count', 'virtualmachine_count', 'allocated_vcpus', 'allocated_memory', 'allocated_disk' ] brief_fields = ('id', 'url', 'display', 'name', 'description', 'virtualmachine_count') - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_scope(self, obj): - if obj.scope_id is None: - return None - serializer = get_serializer_for_model(obj.scope) - context = {'request': self.context['request']} - return serializer(obj.scope, nested=True, context=context).data diff --git a/netbox/vpn/api/serializers_/l2vpn.py b/netbox/vpn/api/serializers_/l2vpn.py index f9e9a9a97..874ae342e 100644 --- a/netbox/vpn/api/serializers_/l2vpn.py +++ b/netbox/vpn/api/serializers_/l2vpn.py @@ -1,13 +1,11 @@ from django.contrib.contenttypes.models import ContentType -from drf_spectacular.utils import extend_schema_field -from rest_framework import serializers from ipam.api.serializers_.vrfs import RouteTargetSerializer from ipam.models import RouteTarget from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField +from netbox.api.gfk_fields import GFKSerializerField from netbox.api.serializers import NetBoxModelSerializer, PrimaryModelSerializer from tenancy.api.serializers_.tenants import TenantSerializer -from utilities.api import get_serializer_for_model from vpn.choices import * from vpn.models import L2VPN, L2VPNTermination @@ -53,7 +51,7 @@ class L2VPNTerminationSerializer(NetBoxModelSerializer): assigned_object_type = ContentTypeField( queryset=ContentType.objects.all() ) - assigned_object = serializers.SerializerMethodField(read_only=True) + assigned_object = GFKSerializerField(read_only=True) class Meta: model = L2VPNTermination @@ -62,9 +60,3 @@ class L2VPNTerminationSerializer(NetBoxModelSerializer): 'assigned_object', 'tags', 'custom_fields', 'created', 'last_updated' ] brief_fields = ('id', 'url', 'display', 'l2vpn') - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_assigned_object(self, instance): - serializer = get_serializer_for_model(instance.assigned_object) - context = {'request': self.context['request']} - return serializer(instance.assigned_object, nested=True, context=context).data diff --git a/netbox/vpn/api/serializers_/tunnels.py b/netbox/vpn/api/serializers_/tunnels.py index dfeb0339f..5bb12d9b4 100644 --- a/netbox/vpn/api/serializers_/tunnels.py +++ b/netbox/vpn/api/serializers_/tunnels.py @@ -1,12 +1,10 @@ from django.contrib.contenttypes.models import ContentType -from drf_spectacular.utils import extend_schema_field -from rest_framework import serializers from ipam.api.serializers_.ip import IPAddressSerializer from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField +from netbox.api.gfk_fields import GFKSerializerField from netbox.api.serializers import NetBoxModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer from tenancy.api.serializers_.tenants import TenantSerializer -from utilities.api import get_serializer_for_model from vpn.choices import * from vpn.models import Tunnel, TunnelGroup, TunnelTermination from .crypto import IPSecProfileSerializer @@ -83,9 +81,7 @@ class TunnelTerminationSerializer(NetBoxModelSerializer): termination_type = ContentTypeField( queryset=ContentType.objects.all() ) - termination = serializers.SerializerMethodField( - read_only=True - ) + termination = GFKSerializerField(read_only=True) outside_ip = IPAddressSerializer( nested=True, required=False, @@ -99,11 +95,3 @@ class TunnelTerminationSerializer(NetBoxModelSerializer): 'termination', 'outside_ip', 'tags', 'custom_fields', 'created', 'last_updated', ) brief_fields = ('id', 'url', 'display') - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_termination(self, obj): - if not obj.termination: - return None - serializer = get_serializer_for_model(obj.termination) - context = {'request': self.context['request']} - return serializer(obj.termination, nested=True, context=context).data diff --git a/netbox/wireless/api/serializers_/wirelesslans.py b/netbox/wireless/api/serializers_/wirelesslans.py index 7403fe860..517e93863 100644 --- a/netbox/wireless/api/serializers_/wirelesslans.py +++ b/netbox/wireless/api/serializers_/wirelesslans.py @@ -1,13 +1,12 @@ from django.contrib.contenttypes.models import ContentType -from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from dcim.constants import LOCATION_SCOPE_TYPES from ipam.api.serializers_.vlans import VLANSerializer from netbox.api.fields import ChoiceField, ContentTypeField +from netbox.api.gfk_fields import GFKSerializerField from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer from tenancy.api.serializers_.tenants import TenantSerializer -from utilities.api import get_serializer_for_model from wireless.choices import * from wireless.models import WirelessLAN, WirelessLANGroup from .nested import NestedWirelessLANGroupSerializer @@ -47,7 +46,7 @@ class WirelessLANSerializer(PrimaryModelSerializer): default=None ) scope_id = serializers.IntegerField(allow_null=True, required=False, default=None) - scope = serializers.SerializerMethodField(read_only=True) + scope = GFKSerializerField(read_only=True) class Meta: model = WirelessLAN @@ -57,11 +56,3 @@ class WirelessLANSerializer(PrimaryModelSerializer): 'tags', 'custom_fields', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'ssid', 'description') - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_scope(self, obj): - if obj.scope_id is None: - return None - serializer = get_serializer_for_model(obj.scope) - context = {'request': self.context['request']} - return serializer(obj.scope, nested=True, context=context).data From 59899d0d9a692c8fe1f93e09bed36eb0cf90e1c2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 4 Nov 2025 16:49:56 -0500 Subject: [PATCH 062/125] Lots of cleanup --- netbox/dcim/views.py | 2 +- netbox/netbox/ui/actions.py | 73 +++++++++-- netbox/netbox/ui/panels.py | 116 ++++++++++++++++-- netbox/templates/generic/object.html | 2 +- netbox/templates/ui/action.html | 6 + netbox/templates/ui/panels/_base.html | 7 +- .../utilities/templatetags/builtins/tags.py | 4 +- 7 files changed, 184 insertions(+), 26 deletions(-) create mode 100644 netbox/templates/ui/action.html diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 8fe7db406..e0274f660 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1202,7 +1202,7 @@ class RackReservationView(generic.ObjectView): layout = layout.Layout( layout.Row( layout.Column( - panels.RackPanel(accessor='rack', only=['region', 'site', 'location']), + panels.RackPanel(title=_('Rack'), accessor='rack', only=['region', 'site', 'location']), CustomFieldsPanel(), TagsPanel(), CommentsPanel(), diff --git a/netbox/netbox/ui/actions.py b/netbox/netbox/ui/actions.py index 10be487c8..8a3d7ecb1 100644 --- a/netbox/netbox/ui/actions.py +++ b/netbox/netbox/ui/actions.py @@ -1,6 +1,7 @@ from urllib.parse import urlencode from django.apps import apps +from django.template.loader import render_to_string from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -14,48 +15,102 @@ __all__ = ( class PanelAction: + """ + A link (typically a button) within a panel to perform some associated action, such as adding an object. + + Attributes: + template_name: The name of the template to render + label: The default human-friendly button text + button_class: Bootstrap CSS class for the button + button_icon: Name of the button's MDI icon + """ + template_name = 'ui/action.html' label = None button_class = 'primary' button_icon = None def __init__(self, view_name, view_kwargs=None, url_params=None, permissions=None, label=None): + """ + Initialize a new PanelAction. + + Parameters: + view_name: Name of the view to which the action will link + view_kwargs: Additional keyword arguments to pass to the view when resolving its URL + url_params: A dictionary of arbitrary URL parameters to append to the action's URL + permissions: A list of permissions required to display the action + label: The human-friendly button text + """ self.view_name = view_name - self.view_kwargs = view_kwargs + self.view_kwargs = view_kwargs or {} self.url_params = url_params or {} self.permissions = permissions if label is not None: self.label = label def get_url(self, context): - url = reverse(self.view_name, kwargs=self.view_kwargs or {}) + """ + Resolve the URL for the action from its view name and kwargs. Append any additional URL parameters. + + Parameters: + context: The template context + """ + url = reverse(self.view_name, kwargs=self.view_kwargs) if self.url_params: + # If the param value is callable, call it with the context and save the result. url_params = { k: v(context) if callable(v) else v for k, v in self.url_params.items() } + # Set the return URL if not already set and an object is available. if 'return_url' not in url_params and 'object' in context: url_params['return_url'] = context['object'].get_absolute_url() url = f'{url}?{urlencode(url_params)}' return url - def get_context(self, context): - return { + def render(self, context): + """ + Render the action as HTML. + + Parameters: + context: The template context + """ + # Enforce permissions + user = context['request'].user + if not user.has_perms(self.permissions): + return '' + + return render_to_string(self.template_name, { 'url': self.get_url(context), 'label': self.label, 'button_class': self.button_class, 'button_icon': self.button_icon, - } + }) class AddObject(PanelAction): + """ + An action to add a new object. + """ label = _('Add') button_icon = 'plus-thick' - def __init__(self, model, label=None, url_params=None): + def __init__(self, model, url_params=None, label=None): + """ + Initialize a new AddObject action. + + Parameters: + model: The dotted label of the model to be added (e.g. "dcim.site") + url_params: A dictionary of arbitrary URL parameters to append to the resolved URL + label: The human-friendly button text + """ # Resolve the model class from its app.name label - app_label, model_name = model.split('.') - model = apps.get_model(app_label, model_name) + try: + app_label, model_name = model.split('.') + model = apps.get_model(app_label, model_name) + except (ValueError, LookupError): + raise ValueError(f"Invalid model label: {model}") view_name = get_viewname(model, 'add') + super().__init__(view_name=view_name, label=label, url_params=url_params) - # Require "add" permission on the model by default + # Require "add" permission on the model self.permissions = [get_permission_for_model(model, 'add')] diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index 05eae3e36..53a6f0792 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -24,24 +24,52 @@ __all__ = ( class Panel(ABC): + """ + A block of content rendered within an HTML template. + + Attributes: + template_name: The name of the template to render + title: The human-friendly title of the panel + actions: A list of PanelActions to include in the panel header + """ template_name = None title = None actions = [] def __init__(self, title=None, actions=None): + """ + Instantiate a new Panel. + + Parameters: + title: The human-friendly title of the panel + actions: A list of PanelActions to include in the panel header + """ if title is not None: self.title = title if actions is not None: self.actions = actions def get_context(self, context): + """ + Return the context data to be used when rendering the panel. + + Parameters: + context: The template context + """ return { 'request': context.get('request'), + 'object': context.get('object'), 'title': self.title, - 'actions': [action.get_context(context) for action in self.actions], + 'actions': self.actions, } def render(self, context): + """ + Render the panel as HTML. + + Parameters: + context: The template context + """ return render_to_string(self.template_name, self.get_context(context)) @@ -73,21 +101,43 @@ class ObjectPanelMeta(ABCMeta): class ObjectPanel(Panel, metaclass=ObjectPanelMeta): - accessor = None + """ + A panel which displays selected attributes of an object. + + Attributes: + template_name: The name of the template to render + accessor: The name of the attribute on the object + """ template_name = 'ui/panels/object.html' + accessor = None def __init__(self, accessor=None, only=None, exclude=None, **kwargs): + """ + Instantiate a new ObjectPanel. + + Parameters: + accessor: The name of the attribute on the object + only: If specified, only attributes in this list will be displayed + exclude: If specified, attributes in this list will be excluded from display + """ super().__init__(**kwargs) + if accessor is not None: self.accessor = accessor # Set included/excluded attributes if only is not None and exclude is not None: - raise ValueError("attrs and exclude cannot both be specified.") + raise ValueError("only and exclude cannot both be specified.") self.only = only or [] self.exclude = exclude or [] def get_context(self, context): + """ + Return the context data to be used when rendering the panel. + + Parameters: + context: The template context + """ # Determine which attributes to display in the panel based on only/exclude args attr_names = set(self._attrs.keys()) if self.only: @@ -99,7 +149,6 @@ class ObjectPanel(Panel, metaclass=ObjectPanelMeta): return { **super().get_context(context), - 'object': obj, 'attrs': [ { 'label': attr.label or title(name), @@ -110,24 +159,42 @@ class ObjectPanel(Panel, metaclass=ObjectPanelMeta): class OrganizationalObjectPanel(ObjectPanel, metaclass=ObjectPanelMeta): + """ + An ObjectPanel with attributes common to OrganizationalModels. + """ name = attrs.TextAttr('name', label=_('Name')) description = attrs.TextAttr('description', label=_('Description')) class NestedGroupObjectPanel(OrganizationalObjectPanel, metaclass=ObjectPanelMeta): + """ + An ObjectPanel with attributes common to NestedGroupObjects. + """ parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True) class CommentsPanel(Panel): + """ + A panel which displays comments associated with an object. + """ template_name = 'ui/panels/comments.html' title = _('Comments') class RelatedObjectsPanel(Panel): + """ + A panel which displays the types and counts of related objects. + """ template_name = 'ui/panels/related_objects.html' title = _('Related Objects') def get_context(self, context): + """ + Return the context data to be used when rendering the panel. + + Parameters: + context: The template context + """ return { **super().get_context(context), 'related_models': context.get('related_models'), @@ -135,20 +202,42 @@ class RelatedObjectsPanel(Panel): class ObjectsTablePanel(Panel): + """ + A panel which displays a table of objects (rendered via HTMX). + """ template_name = 'ui/panels/objects_table.html' title = None def __init__(self, model, filters=None, **kwargs): + """ + Instantiate a new ObjectsTablePanel. + + Parameters: + model: The dotted label of the model to be added (e.g. "dcim.site") + filters: A dictionary of arbitrary URL parameters to append to the table's URL + """ super().__init__(**kwargs) # Resolve the model class from its app.name label - app_label, model_name = model.split('.') - self.model = apps.get_model(app_label, model_name) + try: + app_label, model_name = model.split('.') + self.model = apps.get_model(app_label, model_name) + except (ValueError, LookupError): + raise ValueError(f"Invalid model label: {model}") + self.filters = filters or {} + + # If no title is specified, derive one from the model name if self.title is None: self.title = title(self.model._meta.verbose_name_plural) def get_context(self, context): + """ + Return the context data to be used when rendering the panel. + + Parameters: + context: The template context + """ url_params = { k: v(context) if callable(v) else v for k, v in self.filters.items() } @@ -162,8 +251,16 @@ class ObjectsTablePanel(Panel): class TemplatePanel(Panel): - + """ + A panel which renders content using an HTML template. + """ def __init__(self, template_name, **kwargs): + """ + Instantiate a new TemplatePanel. + + Parameters: + template_name: The name of the template to render + """ super().__init__(**kwargs) self.template_name = template_name @@ -173,7 +270,12 @@ class TemplatePanel(Panel): class PluginContentPanel(Panel): + """ + A panel which displays embedded plugin content. + Parameters: + method: The name of the plugin method to render (e.g. left_page) + """ def __init__(self, method, **kwargs): super().__init__(**kwargs) self.method = method diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index a9783178a..100d5bde7 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -129,7 +129,7 @@ Context: {% for column in row.columns %}
{% for panel in column.panels %} - {% render_panel panel %} + {% render panel %} {% endfor %}
{% endfor %} diff --git a/netbox/templates/ui/action.html b/netbox/templates/ui/action.html new file mode 100644 index 000000000..c61357312 --- /dev/null +++ b/netbox/templates/ui/action.html @@ -0,0 +1,6 @@ + + {% if button_icon %} + + {% endif %} + {{ label }} + diff --git a/netbox/templates/ui/panels/_base.html b/netbox/templates/ui/panels/_base.html index 47ae689b7..1f11b020f 100644 --- a/netbox/templates/ui/panels/_base.html +++ b/netbox/templates/ui/panels/_base.html @@ -4,12 +4,7 @@ {% if actions %}
{% for action in actions %} - - {% if action.button_icon %} - - {% endif %} - {{ action.label }} - + {% render action %} {% endfor %}
{% endif %} diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py index 92c68f052..cab4f9f20 100644 --- a/netbox/utilities/templatetags/builtins/tags.py +++ b/netbox/utilities/templatetags/builtins/tags.py @@ -183,5 +183,5 @@ def static_with_params(path, **params): @register.simple_tag(takes_context=True) -def render_panel(context, panel): - return mark_safe(panel.render(context)) +def render(context, component): + return mark_safe(component.render(context)) From d5cec3723ef8dfc3bbafa88fdd28d9675377fc79 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 4 Nov 2025 17:14:24 -0500 Subject: [PATCH 063/125] Introduce SimpleLayout --- netbox/dcim/views.py | 391 ++++++++++++++++--------------------- netbox/netbox/ui/layout.py | 31 ++- netbox/netbox/ui/panels.py | 1 + 3 files changed, 197 insertions(+), 226 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index e0274f660..3d2dad903 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -21,7 +21,8 @@ from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from netbox.object_actions import * from netbox.ui import actions, layout from netbox.ui.panels import ( - CommentsPanel, NestedGroupObjectPanel, ObjectsTablePanel, PluginContentPanel, RelatedObjectsPanel, TemplatePanel, + CommentsPanel, NestedGroupObjectPanel, ObjectsTablePanel, OrganizationalObjectPanel, RelatedObjectsPanel, + TemplatePanel, ) from netbox.views import generic from utilities.forms import ConfirmationForm @@ -228,33 +229,26 @@ class RegionListView(generic.ObjectListView): @register_model_view(Region) class RegionView(GetRelatedModelsMixin, generic.ObjectView): queryset = Region.objects.all() - layout = layout.Layout( - layout.Row( - layout.Column( - NestedGroupObjectPanel(), - TagsPanel(), - CustomFieldsPanel(), - CommentsPanel(), - PluginContentPanel('left_page'), + layout = layout.SimpleLayout( + left_panels=[ + NestedGroupObjectPanel(), + TagsPanel(), + CustomFieldsPanel(), + CommentsPanel(), + ], + right_panels=[ + RelatedObjectsPanel(), + ], + bottom_panels=[ + ObjectsTablePanel( + model='dcim.Region', + title=_('Child Regions'), + filters={'parent_id': lambda ctx: ctx['object'].pk}, + actions=[ + actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}), + ], ), - layout.Column( - RelatedObjectsPanel(), - PluginContentPanel('right_page'), - ), - ), - layout.Row( - layout.Column( - ObjectsTablePanel( - model='dcim.Region', - title=_('Child Regions'), - filters={'parent_id': lambda ctx: ctx['object'].pk}, - actions=[ - actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}), - ], - ), - PluginContentPanel('full_width_page'), - ), - ), + ] ) def get_extra_context(self, request, instance): @@ -367,33 +361,26 @@ class SiteGroupListView(generic.ObjectListView): @register_model_view(SiteGroup) class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView): queryset = SiteGroup.objects.all() - layout = layout.Layout( - layout.Row( - layout.Column( - NestedGroupObjectPanel(), - TagsPanel(), - CustomFieldsPanel(), - CommentsPanel(), - PluginContentPanel('left_page'), + layout = layout.SimpleLayout( + left_panels=[ + NestedGroupObjectPanel(), + TagsPanel(), + CustomFieldsPanel(), + CommentsPanel(), + ], + right_panels=[ + RelatedObjectsPanel(), + ], + bottom_panels=[ + ObjectsTablePanel( + model='dcim.SiteGroup', + title=_('Child Groups'), + filters={'parent_id': lambda ctx: ctx['object'].pk}, + actions=[ + actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}), + ], ), - layout.Column( - RelatedObjectsPanel(), - PluginContentPanel('right_page'), - ), - ), - layout.Row( - layout.Column( - ObjectsTablePanel( - model='dcim.SiteGroup', - title=_('Child Groups'), - filters={'parent_id': lambda ctx: ctx['object'].pk}, - actions=[ - actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}), - ], - ), - PluginContentPanel('full_width_page'), - ), - ), + ] ) def get_extra_context(self, request, instance): @@ -524,45 +511,38 @@ class SiteListView(generic.ObjectListView): @register_model_view(Site) class SiteView(GetRelatedModelsMixin, generic.ObjectView): queryset = Site.objects.prefetch_related('tenant__group') - layout = layout.Layout( - layout.Row( - layout.Column( - panels.SitePanel(), - CustomFieldsPanel(), - TagsPanel(), - CommentsPanel(), - PluginContentPanel('left_page'), + layout = layout.SimpleLayout( + left_panels=[ + panels.SitePanel(), + CustomFieldsPanel(), + TagsPanel(), + CommentsPanel(), + ], + right_panels=[ + RelatedObjectsPanel(), + ImageAttachmentsPanel(), + ], + bottom_panels=[ + ObjectsTablePanel( + model='dcim.Location', + filters={'site_id': lambda ctx: ctx['object'].pk}, + actions=[ + actions.AddObject('dcim.Location', url_params={'site': lambda ctx: ctx['object'].pk}), + ], ), - layout.Column( - RelatedObjectsPanel(), - ImageAttachmentsPanel(), - PluginContentPanel('right_page'), + ObjectsTablePanel( + model='dcim.Device', + title=_('Non-Racked Devices'), + filters={ + 'site_id': lambda ctx: ctx['object'].pk, + 'rack_id': settings.FILTERS_NULL_CHOICE_VALUE, + 'parent_bay_id': settings.FILTERS_NULL_CHOICE_VALUE, + }, + actions=[ + actions.AddObject('dcim.Device', url_params={'site': lambda ctx: ctx['object'].pk}), + ], ), - ), - layout.Row( - layout.Column( - ObjectsTablePanel( - model='dcim.Location', - filters={'site_id': lambda ctx: ctx['object'].pk}, - actions=[ - actions.AddObject('dcim.Location', url_params={'site': lambda ctx: ctx['object'].pk}), - ], - ), - ObjectsTablePanel( - model='dcim.Device', - title=_('Non-Racked Devices'), - filters={ - 'site_id': lambda ctx: ctx['object'].pk, - 'rack_id': settings.FILTERS_NULL_CHOICE_VALUE, - 'parent_bay_id': settings.FILTERS_NULL_CHOICE_VALUE, - }, - actions=[ - actions.AddObject('dcim.Device', url_params={'site': lambda ctx: ctx['object'].pk}), - ], - ), - PluginContentPanel('full_width_page'), - ), - ), + ] ) def get_extra_context(self, request, instance): @@ -664,58 +644,51 @@ class LocationListView(generic.ObjectListView): @register_model_view(Location) class LocationView(GetRelatedModelsMixin, generic.ObjectView): queryset = Location.objects.all() - layout = layout.Layout( - layout.Row( - layout.Column( - panels.LocationPanel(), - TagsPanel(), - CustomFieldsPanel(), - CommentsPanel(), - PluginContentPanel('left_page'), + layout = layout.SimpleLayout( + left_panels=[ + panels.LocationPanel(), + TagsPanel(), + CustomFieldsPanel(), + CommentsPanel(), + ], + right_panels=[ + RelatedObjectsPanel(), + ImageAttachmentsPanel(), + ], + bottom_panels=[ + ObjectsTablePanel( + model='dcim.Location', + title=_('Child Locations'), + filters={'parent_id': lambda ctx: ctx['object'].pk}, + actions=[ + actions.AddObject( + 'dcim.Location', + url_params={ + 'site': lambda ctx: ctx['object'].site_id, + 'parent': lambda ctx: ctx['object'].pk, + } + ), + ], ), - layout.Column( - RelatedObjectsPanel(), - ImageAttachmentsPanel(), - PluginContentPanel('right_page'), + ObjectsTablePanel( + model='dcim.Device', + title=_('Non-Racked Devices'), + filters={ + 'location_id': lambda ctx: ctx['object'].pk, + 'rack_id': settings.FILTERS_NULL_CHOICE_VALUE, + 'parent_bay_id': settings.FILTERS_NULL_CHOICE_VALUE, + }, + actions=[ + actions.AddObject( + 'dcim.Device', + url_params={ + 'site': lambda ctx: ctx['object'].site_id, + 'parent': lambda ctx: ctx['object'].pk, + } + ), + ], ), - ), - layout.Row( - layout.Column( - ObjectsTablePanel( - model='dcim.Location', - title=_('Child Locations'), - filters={'parent_id': lambda ctx: ctx['object'].pk}, - actions=[ - actions.AddObject( - 'dcim.Location', - url_params={ - 'site': lambda ctx: ctx['object'].site_id, - 'parent': lambda ctx: ctx['object'].pk, - } - ), - ], - ), - ObjectsTablePanel( - model='dcim.Device', - title=_('Non-Racked Devices'), - filters={ - 'location_id': lambda ctx: ctx['object'].pk, - 'rack_id': settings.FILTERS_NULL_CHOICE_VALUE, - 'parent_bay_id': settings.FILTERS_NULL_CHOICE_VALUE, - }, - actions=[ - actions.AddObject( - 'dcim.Device', - url_params={ - 'site': lambda ctx: ctx['object'].site_id, - 'parent': lambda ctx: ctx['object'].pk, - } - ), - ], - ), - PluginContentPanel('full_width_page'), - ), - ), + ] ) def get_extra_context(self, request, instance): @@ -817,24 +790,15 @@ class RackRoleListView(generic.ObjectListView): @register_model_view(RackRole) class RackRoleView(GetRelatedModelsMixin, generic.ObjectView): queryset = RackRole.objects.all() - layout = layout.Layout( - layout.Row( - layout.Column( - panels.RackRolePanel(), - TagsPanel(), - PluginContentPanel('left_page'), - ), - layout.Column( - RelatedObjectsPanel(), - CustomFieldsPanel(), - PluginContentPanel('right_page'), - ), - ), - layout.Row( - layout.Column( - PluginContentPanel('full_width_page'), - ), - ), + layout = layout.SimpleLayout( + left_panels=[ + panels.RackRolePanel(), + TagsPanel(), + ], + right_panels=[ + RelatedObjectsPanel(), + CustomFieldsPanel(), + ], ) def get_extra_context(self, request, instance): @@ -903,28 +867,19 @@ class RackTypeListView(generic.ObjectListView): @register_model_view(RackType) class RackTypeView(GetRelatedModelsMixin, generic.ObjectView): queryset = RackType.objects.all() - layout = layout.Layout( - layout.Row( - layout.Column( - panels.RackTypePanel(), - panels.RackDimensionsPanel(title=_('Dimensions')), - TagsPanel(), - CommentsPanel(), - PluginContentPanel('left_page'), - ), - layout.Column( - panels.RackNumberingPanel(title=_('Numbering')), - panels.RackWeightPanel(title=_('Weight'), exclude=['total_weight']), - CustomFieldsPanel(), - RelatedObjectsPanel(), - PluginContentPanel('right_page'), - ), - ), - layout.Row( - layout.Column( - PluginContentPanel('full_width_page'), - ), - ), + layout = layout.SimpleLayout( + left_panels=[ + panels.RackTypePanel(), + panels.RackDimensionsPanel(title=_('Dimensions')), + TagsPanel(), + CommentsPanel(), + ], + right_panels=[ + panels.RackNumberingPanel(title=_('Numbering')), + panels.RackWeightPanel(title=_('Weight'), exclude=['total_weight']), + CustomFieldsPanel(), + RelatedObjectsPanel(), + ], ) def get_extra_context(self, request, instance): @@ -1043,30 +998,21 @@ class RackElevationListView(generic.ObjectListView): @register_model_view(Rack) class RackView(GetRelatedModelsMixin, generic.ObjectView): queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role') - layout = layout.Layout( - layout.Row( - layout.Column( - panels.RackPanel(), - panels.RackDimensionsPanel(title=_('Dimensions')), - panels.RackNumberingPanel(title=_('Numbering')), - panels.RackWeightPanel(title=_('Weight')), - CustomFieldsPanel(), - TagsPanel(), - CommentsPanel(), - ImageAttachmentsPanel(), - PluginContentPanel('left_page'), - ), - layout.Column( - TemplatePanel('dcim/panels/rack_elevations.html'), - RelatedObjectsPanel(), - PluginContentPanel('right_page'), - ), - ), - layout.Row( - layout.Column( - PluginContentPanel('full_width_page'), - ), - ), + layout = layout.SimpleLayout( + left_panels=[ + panels.RackPanel(), + panels.RackDimensionsPanel(title=_('Dimensions')), + panels.RackNumberingPanel(title=_('Numbering')), + panels.RackWeightPanel(title=_('Weight')), + CustomFieldsPanel(), + TagsPanel(), + CommentsPanel(), + ImageAttachmentsPanel(), + ], + right_panels=[ + TemplatePanel('dcim/panels/rack_elevations.html'), + RelatedObjectsPanel(), + ], ) def get_extra_context(self, request, instance): @@ -1199,27 +1145,18 @@ class RackReservationListView(generic.ObjectListView): @register_model_view(RackReservation) class RackReservationView(generic.ObjectView): queryset = RackReservation.objects.all() - layout = layout.Layout( - layout.Row( - layout.Column( - panels.RackPanel(title=_('Rack'), accessor='rack', only=['region', 'site', 'location']), - CustomFieldsPanel(), - TagsPanel(), - CommentsPanel(), - ImageAttachmentsPanel(), - PluginContentPanel('left_page'), - ), - layout.Column( - TemplatePanel('dcim/panels/rack_elevations.html'), - RelatedObjectsPanel(), - PluginContentPanel('right_page'), - ), - ), - layout.Row( - layout.Column( - PluginContentPanel('full_width_page'), - ), - ), + layout = layout.SimpleLayout( + left_panels=[ + panels.RackPanel(accessor='rack', only=['region', 'site', 'location']), + CustomFieldsPanel(), + TagsPanel(), + CommentsPanel(), + ImageAttachmentsPanel(), + ], + right_panels=[ + TemplatePanel('dcim/panels/rack_elevations.html'), + RelatedObjectsPanel(), + ], ) @@ -1294,6 +1231,10 @@ class ManufacturerListView(generic.ObjectListView): @register_model_view(Manufacturer) class ManufacturerView(GetRelatedModelsMixin, generic.ObjectView): queryset = Manufacturer.objects.all() + layout = layout.SimpleLayout( + left_panels=[OrganizationalObjectPanel(), TagsPanel()], + right_panels=[RelatedObjectsPanel(), CustomFieldsPanel()], + ) def get_extra_context(self, request, instance): return { diff --git a/netbox/netbox/ui/layout.py b/netbox/netbox/ui/layout.py index a314a597b..6612917a7 100644 --- a/netbox/netbox/ui/layout.py +++ b/netbox/netbox/ui/layout.py @@ -1,12 +1,17 @@ -from netbox.ui.panels import Panel +from netbox.ui.panels import Panel, PluginContentPanel __all__ = ( 'Column', 'Layout', 'Row', + 'SimpleLayout', ) +# +# Base classes +# + class Layout: def __init__(self, *rows): @@ -32,3 +37,27 @@ class Column: if not isinstance(panel, Panel): raise TypeError(f"Panel {i} must be an instance of a Panel, not {type(panel)}.") self.panels = panels + + +# +# Standard layouts +# + +class SimpleLayout(Layout): + """ + A layout with one row of two columns and a second row with one column. Includes registered plugin content. + """ + def __init__(self, left_panels=None, right_panels=None, bottom_panels=None): + left_panels = left_panels or [] + right_panels = right_panels or [] + bottom_panels = bottom_panels or [] + rows = [ + Row( + Column(*left_panels, PluginContentPanel('left_page')), + Column(*right_panels, PluginContentPanel('right_page')), + ), + Row( + Column(*bottom_panels, PluginContentPanel('full_width_page')) + ) + ] + super().__init__(*rows) diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index 53a6f0792..b2f7ad2eb 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -149,6 +149,7 @@ class ObjectPanel(Panel, metaclass=ObjectPanelMeta): return { **super().get_context(context), + 'title': self.title or title(obj._meta.verbose_name), 'attrs': [ { 'label': attr.label or title(name), From 1de41b4964909b37ac7fff9b1ba2c060574343e2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 4 Nov 2025 20:06:18 -0500 Subject: [PATCH 064/125] Add layouts for DeviceType & ModuleTypeProfile --- netbox/dcim/ui/panels.py | 21 +++++++++ netbox/dcim/views.py | 42 +++++++++++++++++- netbox/netbox/ui/actions.py | 30 ++++++++++++- netbox/netbox/ui/attrs.py | 14 ++++++ netbox/netbox/ui/panels.py | 43 +++++++++++++++++-- netbox/templates/ui/actions/copy_content.html | 7 +++ .../ui/{action.html => actions/link.html} | 2 +- netbox/templates/ui/attrs/boolean.html | 2 +- netbox/templates/ui/attrs/image.html | 3 ++ netbox/templates/ui/panels/json.html | 5 +++ 10 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 netbox/templates/ui/actions/copy_content.html rename netbox/templates/ui/{action.html => actions/link.html} (56%) create mode 100644 netbox/templates/ui/attrs/image.html create mode 100644 netbox/templates/ui/panels/json.html diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index d26dfda45..d6309c600 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -112,3 +112,24 @@ class DeviceManagementPanel(panels.ObjectPanel): label=_('Out-of-band IP'), template_name='dcim/device/attrs/ipaddress.html', ) + + +class DeviceTypePanel(panels.ObjectPanel): + manufacturer = attrs.ObjectAttr('manufacturer', label=_('Manufacturer'), linkify=True) + model = attrs.TextAttr('model', label=_('Model')) + part_number = attrs.TextAttr('part_number', label=_('Part number')) + default_platform = attrs.ObjectAttr('default_platform', label=_('Default platform'), linkify=True) + description = attrs.TextAttr('description', label=_('Description')) + u_height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height')) + exclude_from_utilization = attrs.BooleanAttr('exclude_from_utilization', label=_('Exclude from utilization')) + full_depth = attrs.BooleanAttr('is_full_depth', label=_('Full depth')) + weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display', label=_('Weight')) + subdevice_role = attrs.ChoiceAttr('subdevice_role', label=_('Parent/child')) + airflow = attrs.ChoiceAttr('airflow', label=_('Airflow')) + front_image = attrs.ImageAttr('front_image', label=_('Front image')) + rear_image = attrs.ImageAttr('rear_image', label=_('Rear image')) + + +class ModuleTypeProfilePanel(panels.ObjectPanel): + name = attrs.TextAttr('name', label=_('Name')) + description = attrs.TextAttr('description', label=_('Description')) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 3d2dad903..051d2867d 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -21,7 +21,7 @@ from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from netbox.object_actions import * from netbox.ui import actions, layout from netbox.ui.panels import ( - CommentsPanel, NestedGroupObjectPanel, ObjectsTablePanel, OrganizationalObjectPanel, RelatedObjectsPanel, + CommentsPanel, JSONPanel, NestedGroupObjectPanel, ObjectsTablePanel, OrganizationalObjectPanel, RelatedObjectsPanel, TemplatePanel, ) from netbox.views import generic @@ -1308,6 +1308,18 @@ class DeviceTypeListView(generic.ObjectListView): @register_model_view(DeviceType) class DeviceTypeView(GetRelatedModelsMixin, generic.ObjectView): queryset = DeviceType.objects.all() + layout = layout.SimpleLayout( + left_panels=[ + panels.DeviceTypePanel(), + TagsPanel(), + ], + right_panels=[ + RelatedObjectsPanel(), + CustomFieldsPanel(), + CommentsPanel(), + ImageAttachmentsPanel(), + ], + ) def get_extra_context(self, request, instance): return { @@ -1559,6 +1571,34 @@ class ModuleTypeProfileListView(generic.ObjectListView): @register_model_view(ModuleTypeProfile) class ModuleTypeProfileView(GetRelatedModelsMixin, generic.ObjectView): queryset = ModuleTypeProfile.objects.all() + layout = layout.SimpleLayout( + left_panels=[ + panels.ModuleTypeProfilePanel(), + TagsPanel(), + CommentsPanel(), + ], + right_panels=[ + JSONPanel(field_name='schema', title=_('Schema')), + CustomFieldsPanel(), + ], + bottom_panels=[ + ObjectsTablePanel( + model='dcim.ModuleType', + title=_('Module Types'), + filters={ + 'profile_id': lambda ctx: ctx['object'].pk, + }, + actions=[ + actions.AddObject( + 'dcim.ModuleType', + url_params={ + 'profile': lambda ctx: ctx['object'].pk, + } + ), + ], + ), + ] + ) @register_model_view(ModuleTypeProfile, 'add', detail=False) diff --git a/netbox/netbox/ui/actions.py b/netbox/netbox/ui/actions.py index 8a3d7ecb1..a94520bdc 100644 --- a/netbox/netbox/ui/actions.py +++ b/netbox/netbox/ui/actions.py @@ -24,11 +24,12 @@ class PanelAction: button_class: Bootstrap CSS class for the button button_icon: Name of the button's MDI icon """ - template_name = 'ui/action.html' + template_name = 'ui/actions/link.html' label = None button_class = 'primary' button_icon = None + # TODO: Refactor URL parameters to AddObject def __init__(self, view_name, view_kwargs=None, url_params=None, permissions=None, label=None): """ Initialize a new PanelAction. @@ -114,3 +115,30 @@ class AddObject(PanelAction): # Require "add" permission on the model self.permissions = [get_permission_for_model(model, 'add')] + + +class CopyContent: + """ + An action to copy the contents of a panel to the clipboard. + """ + template_name = 'ui/actions/copy_content.html' + label = _('Copy') + button_class = 'primary' + button_icon = 'content-copy' + + def __init__(self, target_id): + self.target_id = target_id + + def render(self, context): + """ + Render the action as HTML. + + Parameters: + context: The template context + """ + return render_to_string(self.template_name, { + 'target_id': self.target_id, + 'label': self.label, + 'button_class': self.button_class, + 'button_icon': self.button_icon, + }) diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index 4df8f64e1..72c5dba5f 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -135,6 +135,20 @@ class ColorAttr(Attr): }) +class ImageAttr(Attr): + template_name = 'ui/attrs/image.html' + + def render(self, obj, context=None): + context = context or {} + value = self._resolve_attr(obj, self.accessor) + if value in (None, ''): + return self.placeholder + return render_to_string(self.template_name, { + **context, + 'value': value, + }) + + class ObjectAttr(Attr): template_name = 'ui/attrs/object.html' diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index b2f7ad2eb..eefbde5b4 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -5,6 +5,7 @@ from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ from netbox.ui import attrs +from netbox.ui.actions import CopyContent from utilities.querydict import dict_to_querydict from utilities.string import title from utilities.templatetags.plugins import _get_registered_content @@ -12,6 +13,7 @@ from utilities.views import get_viewname __all__ = ( 'CommentsPanel', + 'JSONPanel', 'NestedGroupObjectPanel', 'ObjectPanel', 'ObjectsTablePanel', @@ -34,7 +36,7 @@ class Panel(ABC): """ template_name = None title = None - actions = [] + actions = None def __init__(self, title=None, actions=None): """ @@ -46,8 +48,7 @@ class Panel(ABC): """ if title is not None: self.title = title - if actions is not None: - self.actions = actions + self.actions = actions or [] def get_context(self, context): """ @@ -251,6 +252,42 @@ class ObjectsTablePanel(Panel): } +class JSONPanel(Panel): + """ + A panel which renders formatted JSON data. + """ + template_name = 'ui/panels/json.html' + + def __init__(self, field_name, copy_button=True, **kwargs): + """ + Instantiate a new JSONPanel. + + Parameters: + field_name: The name of the JSON field on the object + copy_button: Set to True (default) to include a copy-to-clipboard button + """ + super().__init__(**kwargs) + self.field_name = field_name + + if copy_button: + self.actions.append( + CopyContent(f'panel_{field_name}'), + ) + + def get_context(self, context): + """ + Return the context data to be used when rendering the panel. + + Parameters: + context: The template context + """ + return { + **super().get_context(context), + 'data': getattr(context['object'], self.field_name), + 'field_name': self.field_name, + } + + class TemplatePanel(Panel): """ A panel which renders content using an HTML template. diff --git a/netbox/templates/ui/actions/copy_content.html b/netbox/templates/ui/actions/copy_content.html new file mode 100644 index 000000000..67f54354b --- /dev/null +++ b/netbox/templates/ui/actions/copy_content.html @@ -0,0 +1,7 @@ +{% load i18n %} + + {% if button_icon %} + + {% endif %} + {{ label }} + diff --git a/netbox/templates/ui/action.html b/netbox/templates/ui/actions/link.html similarity index 56% rename from netbox/templates/ui/action.html rename to netbox/templates/ui/actions/link.html index c61357312..11c6b6da9 100644 --- a/netbox/templates/ui/action.html +++ b/netbox/templates/ui/actions/link.html @@ -1,4 +1,4 @@ - + {% if button_icon %} {% endif %} diff --git a/netbox/templates/ui/attrs/boolean.html b/netbox/templates/ui/attrs/boolean.html index a724d687b..a7087c94f 100644 --- a/netbox/templates/ui/attrs/boolean.html +++ b/netbox/templates/ui/attrs/boolean.html @@ -1 +1 @@ -{% checkmark object.desc_units %} +{% checkmark value %} diff --git a/netbox/templates/ui/attrs/image.html b/netbox/templates/ui/attrs/image.html new file mode 100644 index 000000000..3c10113c4 --- /dev/null +++ b/netbox/templates/ui/attrs/image.html @@ -0,0 +1,3 @@ + + {{ value.name }} + diff --git a/netbox/templates/ui/panels/json.html b/netbox/templates/ui/panels/json.html new file mode 100644 index 000000000..36d3d4d1a --- /dev/null +++ b/netbox/templates/ui/panels/json.html @@ -0,0 +1,5 @@ +{% extends "ui/panels/_base.html" %} + +{% block panel_content %} +
{{ data|json }}
+{% endblock panel_content %} From 838794a5cf1d77d9b36dfcec27287cb1edc092cb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Nov 2025 10:51:18 -0500 Subject: [PATCH 065/125] Derive attribute labels from name if not passed for instance --- netbox/dcim/ui/panels.py | 138 +++++++++++++++++------------------- netbox/netbox/ui/actions.py | 1 + netbox/netbox/ui/attrs.py | 2 +- netbox/netbox/ui/panels.py | 11 ++- 4 files changed, 79 insertions(+), 73 deletions(-) diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index d6309c600..9d6e301b2 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -4,61 +4,61 @@ from netbox.ui import attrs, panels class SitePanel(panels.ObjectPanel): - region = attrs.NestedObjectAttr('region', label=_('Region'), linkify=True) - group = attrs.NestedObjectAttr('group', label=_('Group'), linkify=True) - status = attrs.ChoiceAttr('status', label=_('Status')) - tenant = attrs.ObjectAttr('tenant', label=_('Tenant'), linkify=True, grouped_by='group') - facility = attrs.TextAttr('facility', label=_('Facility')) - description = attrs.TextAttr('description', label=_('Description')) - timezone = attrs.TimezoneAttr('time_zone', label=_('Timezone')) - physical_address = attrs.AddressAttr('physical_address', label=_('Physical address'), map_url=True) - shipping_address = attrs.AddressAttr('shipping_address', label=_('Shipping address'), map_url=True) + region = attrs.NestedObjectAttr('region', linkify=True) + group = attrs.NestedObjectAttr('group', linkify=True) + status = attrs.ChoiceAttr('status') + tenant = attrs.ObjectAttr('tenant', linkify=True, grouped_by='group') + facility = attrs.TextAttr('facility') + description = attrs.TextAttr('description') + timezone = attrs.TimezoneAttr('time_zone') + physical_address = attrs.AddressAttr('physical_address', map_url=True) + shipping_address = attrs.AddressAttr('shipping_address', map_url=True) gps_coordinates = attrs.GPSCoordinatesAttr() class LocationPanel(panels.NestedGroupObjectPanel): - site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') - status = attrs.ChoiceAttr('status', label=_('Status')) - tenant = attrs.ObjectAttr('tenant', label=_('Tenant'), linkify=True, grouped_by='group') - facility = attrs.TextAttr('facility', label=_('Facility')) + site = attrs.ObjectAttr('site', linkify=True, grouped_by='group') + status = attrs.ChoiceAttr('status') + tenant = attrs.ObjectAttr('tenant', linkify=True, grouped_by='group') + facility = attrs.TextAttr('facility') class RackDimensionsPanel(panels.ObjectPanel): - form_factor = attrs.ChoiceAttr('form_factor', label=_('Form factor')) - width = attrs.ChoiceAttr('width', label=_('Width')) + form_factor = attrs.ChoiceAttr('form_factor') + width = attrs.ChoiceAttr('width') u_height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height')) - outer_width = attrs.NumericAttr('outer_width', unit_accessor='get_outer_unit_display', label=_('Outer width')) - outer_height = attrs.NumericAttr('outer_height', unit_accessor='get_outer_unit_display', label=_('Outer height')) - outer_depth = attrs.NumericAttr('outer_depth', unit_accessor='get_outer_unit_display', label=_('Outer depth')) - mounting_depth = attrs.TextAttr('mounting_depth', format_string='{}mm', label=_('Mounting depth')) + outer_width = attrs.NumericAttr('outer_width', unit_accessor='get_outer_unit_display') + outer_height = attrs.NumericAttr('outer_height', unit_accessor='get_outer_unit_display') + outer_depth = attrs.NumericAttr('outer_depth', unit_accessor='get_outer_unit_display') + mounting_depth = attrs.TextAttr('mounting_depth', format_string='{}mm') class RackNumberingPanel(panels.ObjectPanel): - starting_unit = attrs.TextAttr('starting_unit', label=_('Starting unit')) + starting_unit = attrs.TextAttr('starting_unit') desc_units = attrs.BooleanAttr('desc_units', label=_('Descending units')) class RackPanel(panels.ObjectPanel): - region = attrs.NestedObjectAttr('site.region', label=_('Region'), linkify=True) - site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') - location = attrs.NestedObjectAttr('location', label=_('Location'), linkify=True) - facility = attrs.TextAttr('facility', label=_('Facility ID')) - tenant = attrs.ObjectAttr('tenant', label=_('Tenant'), linkify=True, grouped_by='group') - status = attrs.ChoiceAttr('status', label=_('Status')) - rack_type = attrs.ObjectAttr('rack_type', label=_('Rack type'), linkify=True, grouped_by='manufacturer') - role = attrs.ObjectAttr('role', label=_('Role'), linkify=True) - description = attrs.TextAttr('description', label=_('Description')) + region = attrs.NestedObjectAttr('site.region', linkify=True) + site = attrs.ObjectAttr('site', linkify=True, grouped_by='group') + location = attrs.NestedObjectAttr('location', linkify=True) + facility = attrs.TextAttr('facility') + tenant = attrs.ObjectAttr('tenant', linkify=True, grouped_by='group') + status = attrs.ChoiceAttr('status') + rack_type = attrs.ObjectAttr('rack_type', linkify=True, grouped_by='manufacturer') + role = attrs.ObjectAttr('role', linkify=True) + description = attrs.TextAttr('description') serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True) - asset_tag = attrs.TextAttr('asset_tag', label=_('Asset tag'), style='font-monospace', copy_button=True) - airflow = attrs.ChoiceAttr('airflow', label=_('Airflow')) - space_utilization = attrs.UtilizationAttr('get_utilization', label=_('Space utilization')) - power_utilization = attrs.UtilizationAttr('get_power_utilization', label=_('Power utilization')) + asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True) + airflow = attrs.ChoiceAttr('airflow') + space_utilization = attrs.UtilizationAttr('get_utilization') + power_utilization = attrs.UtilizationAttr('get_power_utilization') class RackWeightPanel(panels.ObjectPanel): - weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display', label=_('Weight')) + weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display') max_weight = attrs.NumericAttr('max_weight', unit_accessor='get_weight_unit_display', label=_('Maximum weight')) - total_weight = attrs.NumericAttr('total_weight', unit_accessor='get_weight_unit_display', label=_('Total weight')) + total_weight = attrs.NumericAttr('total_weight', unit_accessor='get_weight_unit_display') class RackRolePanel(panels.OrganizationalObjectPanel): @@ -66,37 +66,33 @@ class RackRolePanel(panels.OrganizationalObjectPanel): class RackTypePanel(panels.ObjectPanel): - manufacturer = attrs.ObjectAttr('manufacturer', label=_('Manufacturer'), linkify=True) - model = attrs.TextAttr('model', label=_('Model')) - description = attrs.TextAttr('description', label=_('Description')) - airflow = attrs.ChoiceAttr('airflow', label=_('Airflow')) + manufacturer = attrs.ObjectAttr('manufacturer', linkify=True) + model = attrs.TextAttr('model') + description = attrs.TextAttr('description') + airflow = attrs.ChoiceAttr('airflow') class DevicePanel(panels.ObjectPanel): - region = attrs.NestedObjectAttr('site.region', label=_('Region'), linkify=True) - site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') - location = attrs.NestedObjectAttr('location', label=_('Location'), linkify=True) - rack = attrs.TemplatedAttr('rack', label=_('Rack'), template_name='dcim/device/attrs/rack.html') - virtual_chassis = attrs.NestedObjectAttr('virtual_chassis', label=_('Virtual chassis'), linkify=True) - parent_device = attrs.TemplatedAttr( - 'parent_bay', - label=_('Parent device'), - template_name='dcim/device/attrs/parent_device.html', - ) + region = attrs.NestedObjectAttr('site.region', linkify=True) + site = attrs.ObjectAttr('site', linkify=True, grouped_by='group') + location = attrs.NestedObjectAttr('location', linkify=True) + rack = attrs.TemplatedAttr('rack', template_name='dcim/device/attrs/rack.html') + virtual_chassis = attrs.NestedObjectAttr('virtual_chassis', linkify=True) + parent_device = attrs.TemplatedAttr('parent_bay', template_name='dcim/device/attrs/parent_device.html') gps_coordinates = attrs.GPSCoordinatesAttr() - tenant = attrs.ObjectAttr('tenant', label=_('Tenant'), linkify=True, grouped_by='group') - device_type = attrs.ObjectAttr('device_type', label=_('Device type'), linkify=True, grouped_by='manufacturer') - description = attrs.TextAttr('description', label=_('Description')) - airflow = attrs.ChoiceAttr('airflow', label=_('Airflow')) + tenant = attrs.ObjectAttr('tenant', linkify=True, grouped_by='group') + device_type = attrs.ObjectAttr('device_type', linkify=True, grouped_by='manufacturer') + description = attrs.TextAttr('description') + airflow = attrs.ChoiceAttr('airflow') serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True) - asset_tag = attrs.TextAttr('asset_tag', label=_('Asset tag'), style='font-monospace', copy_button=True) - config_template = attrs.ObjectAttr('config_template', label=_('Config template'), linkify=True) + asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True) + config_template = attrs.ObjectAttr('config_template', linkify=True) class DeviceManagementPanel(panels.ObjectPanel): - status = attrs.ChoiceAttr('status', label=_('Status')) - role = attrs.NestedObjectAttr('role', label=_('Role'), linkify=True, max_depth=3) - platform = attrs.NestedObjectAttr('platform', label=_('Platform'), linkify=True, max_depth=3) + status = attrs.ChoiceAttr('status') + role = attrs.NestedObjectAttr('role', linkify=True, max_depth=3) + platform = attrs.NestedObjectAttr('platform', linkify=True, max_depth=3) primary_ip4 = attrs.TemplatedAttr( 'primary_ip4', label=_('Primary IPv4'), @@ -115,21 +111,21 @@ class DeviceManagementPanel(panels.ObjectPanel): class DeviceTypePanel(panels.ObjectPanel): - manufacturer = attrs.ObjectAttr('manufacturer', label=_('Manufacturer'), linkify=True) - model = attrs.TextAttr('model', label=_('Model')) - part_number = attrs.TextAttr('part_number', label=_('Part number')) - default_platform = attrs.ObjectAttr('default_platform', label=_('Default platform'), linkify=True) - description = attrs.TextAttr('description', label=_('Description')) + manufacturer = attrs.ObjectAttr('manufacturer', linkify=True) + model = attrs.TextAttr('model') + part_number = attrs.TextAttr('part_number') + default_platform = attrs.ObjectAttr('default_platform', linkify=True) + description = attrs.TextAttr('description') u_height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height')) - exclude_from_utilization = attrs.BooleanAttr('exclude_from_utilization', label=_('Exclude from utilization')) - full_depth = attrs.BooleanAttr('is_full_depth', label=_('Full depth')) - weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display', label=_('Weight')) + exclude_from_utilization = attrs.BooleanAttr('exclude_from_utilization') + full_depth = attrs.BooleanAttr('is_full_depth') + weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display') subdevice_role = attrs.ChoiceAttr('subdevice_role', label=_('Parent/child')) - airflow = attrs.ChoiceAttr('airflow', label=_('Airflow')) - front_image = attrs.ImageAttr('front_image', label=_('Front image')) - rear_image = attrs.ImageAttr('rear_image', label=_('Rear image')) + airflow = attrs.ChoiceAttr('airflow') + front_image = attrs.ImageAttr('front_image') + rear_image = attrs.ImageAttr('rear_image') class ModuleTypeProfilePanel(panels.ObjectPanel): - name = attrs.TextAttr('name', label=_('Name')) - description = attrs.TextAttr('description', label=_('Description')) + name = attrs.TextAttr('name') + description = attrs.TextAttr('description') diff --git a/netbox/netbox/ui/actions.py b/netbox/netbox/ui/actions.py index a94520bdc..d0a374f58 100644 --- a/netbox/netbox/ui/actions.py +++ b/netbox/netbox/ui/actions.py @@ -10,6 +10,7 @@ from utilities.views import get_viewname __all__ = ( 'AddObject', + 'CopyContent', 'PanelAction', ) diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index 72c5dba5f..235d11a3a 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -221,7 +221,7 @@ class AddressAttr(Attr): class GPSCoordinatesAttr(Attr): template_name = 'ui/attrs/gps_coordinates.html' - label = _('GPS Coordinates') + label = _('GPS coordinates') def __init__(self, latitude_attr='latitude', longitude_attr='longitude', map_url=True, **kwargs): super().__init__(accessor=None, **kwargs) diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index eefbde5b4..f339d77b0 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -132,6 +132,15 @@ class ObjectPanel(Panel, metaclass=ObjectPanelMeta): self.only = only or [] self.exclude = exclude or [] + @staticmethod + def _name_to_label(name): + """ + Format an attribute's name to be presented as a human-friendly label. + """ + label = name[:1].upper() + name[1:] + label = label.replace('_', ' ') + return label + def get_context(self, context): """ Return the context data to be used when rendering the panel. @@ -153,7 +162,7 @@ class ObjectPanel(Panel, metaclass=ObjectPanelMeta): 'title': self.title or title(obj._meta.verbose_name), 'attrs': [ { - 'label': attr.label or title(name), + 'label': attr.label or self._name_to_label(name), 'value': attr.render(obj, {'name': name}), } for name, attr in self._attrs.items() if name in attr_names ], From 281cb4f586455191934eb0b1d85040f690575191 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Nov 2025 13:21:37 -0500 Subject: [PATCH 066/125] Split ObjectPanel into a base class and ObjectAttrsPanel; use base class for e.g. CommentsPanels, JSONPanel, etc. --- netbox/dcim/ui/panels.py | 20 +-- netbox/netbox/ui/attrs.py | 40 ++--- netbox/netbox/ui/panels.py | 166 ++++++++++++------ netbox/templates/ui/panels/comments.html | 4 +- .../{object.html => object_attributes.html} | 0 netbox/utilities/data.py | 21 +++ 6 files changed, 164 insertions(+), 87 deletions(-) rename netbox/templates/ui/panels/{object.html => object_attributes.html} (100%) diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index 9d6e301b2..93043a005 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _ from netbox.ui import attrs, panels -class SitePanel(panels.ObjectPanel): +class SitePanel(panels.ObjectAttributesPanel): region = attrs.NestedObjectAttr('region', linkify=True) group = attrs.NestedObjectAttr('group', linkify=True) status = attrs.ChoiceAttr('status') @@ -23,7 +23,7 @@ class LocationPanel(panels.NestedGroupObjectPanel): facility = attrs.TextAttr('facility') -class RackDimensionsPanel(panels.ObjectPanel): +class RackDimensionsPanel(panels.ObjectAttributesPanel): form_factor = attrs.ChoiceAttr('form_factor') width = attrs.ChoiceAttr('width') u_height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height')) @@ -33,12 +33,12 @@ class RackDimensionsPanel(panels.ObjectPanel): mounting_depth = attrs.TextAttr('mounting_depth', format_string='{}mm') -class RackNumberingPanel(panels.ObjectPanel): +class RackNumberingPanel(panels.ObjectAttributesPanel): starting_unit = attrs.TextAttr('starting_unit') desc_units = attrs.BooleanAttr('desc_units', label=_('Descending units')) -class RackPanel(panels.ObjectPanel): +class RackPanel(panels.ObjectAttributesPanel): region = attrs.NestedObjectAttr('site.region', linkify=True) site = attrs.ObjectAttr('site', linkify=True, grouped_by='group') location = attrs.NestedObjectAttr('location', linkify=True) @@ -55,7 +55,7 @@ class RackPanel(panels.ObjectPanel): power_utilization = attrs.UtilizationAttr('get_power_utilization') -class RackWeightPanel(panels.ObjectPanel): +class RackWeightPanel(panels.ObjectAttributesPanel): weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display') max_weight = attrs.NumericAttr('max_weight', unit_accessor='get_weight_unit_display', label=_('Maximum weight')) total_weight = attrs.NumericAttr('total_weight', unit_accessor='get_weight_unit_display') @@ -65,14 +65,14 @@ class RackRolePanel(panels.OrganizationalObjectPanel): color = attrs.ColorAttr('color') -class RackTypePanel(panels.ObjectPanel): +class RackTypePanel(panels.ObjectAttributesPanel): manufacturer = attrs.ObjectAttr('manufacturer', linkify=True) model = attrs.TextAttr('model') description = attrs.TextAttr('description') airflow = attrs.ChoiceAttr('airflow') -class DevicePanel(panels.ObjectPanel): +class DevicePanel(panels.ObjectAttributesPanel): region = attrs.NestedObjectAttr('site.region', linkify=True) site = attrs.ObjectAttr('site', linkify=True, grouped_by='group') location = attrs.NestedObjectAttr('location', linkify=True) @@ -89,7 +89,7 @@ class DevicePanel(panels.ObjectPanel): config_template = attrs.ObjectAttr('config_template', linkify=True) -class DeviceManagementPanel(panels.ObjectPanel): +class DeviceManagementPanel(panels.ObjectAttributesPanel): status = attrs.ChoiceAttr('status') role = attrs.NestedObjectAttr('role', linkify=True, max_depth=3) platform = attrs.NestedObjectAttr('platform', linkify=True, max_depth=3) @@ -110,7 +110,7 @@ class DeviceManagementPanel(panels.ObjectPanel): ) -class DeviceTypePanel(panels.ObjectPanel): +class DeviceTypePanel(panels.ObjectAttributesPanel): manufacturer = attrs.ObjectAttr('manufacturer', linkify=True) model = attrs.TextAttr('model') part_number = attrs.TextAttr('part_number') @@ -126,6 +126,6 @@ class DeviceTypePanel(panels.ObjectPanel): rear_image = attrs.ImageAttr('rear_image') -class ModuleTypeProfilePanel(panels.ObjectPanel): +class ModuleTypeProfilePanel(panels.ObjectAttributesPanel): name = attrs.TextAttr('name') description = attrs.TextAttr('description') diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index 235d11a3a..1300b4702 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -5,6 +5,7 @@ from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from netbox.config import get_config +from utilities.data import resolve_attr_path # @@ -26,15 +27,6 @@ class Attr(ABC): def render(self, obj, context=None): pass - @staticmethod - def _resolve_attr(obj, path): - cur = obj - for part in path.split('.'): - if cur is None: - return None - cur = getattr(cur, part) if hasattr(cur, part) else cur.get(part) if isinstance(cur, dict) else None - return cur - class TextAttr(Attr): template_name = 'ui/attrs/text.html' @@ -47,7 +39,7 @@ class TextAttr(Attr): def render(self, obj, context=None): context = context or {} - value = self._resolve_attr(obj, self.accessor) + value = resolve_attr_path(obj, self.accessor) if value in (None, ''): return self.placeholder if self.format_string: @@ -70,10 +62,10 @@ class NumericAttr(Attr): def render(self, obj, context=None): context = context or {} - value = self._resolve_attr(obj, self.accessor) + value = resolve_attr_path(obj, self.accessor) if value in (None, ''): return self.placeholder - unit = self._resolve_attr(obj, self.unit_accessor) if self.unit_accessor else None + unit = resolve_attr_path(obj, self.unit_accessor) if self.unit_accessor else None return render_to_string(self.template_name, { **context, 'value': value, @@ -90,7 +82,7 @@ class ChoiceAttr(Attr): try: value = getattr(obj, f'get_{self.accessor}_display')() except AttributeError: - value = self._resolve_attr(obj, self.accessor) + value = resolve_attr_path(obj, self.accessor) if value in (None, ''): return self.placeholder try: @@ -113,7 +105,7 @@ class BooleanAttr(Attr): def render(self, obj, context=None): context = context or {} - value = self._resolve_attr(obj, self.accessor) + value = resolve_attr_path(obj, self.accessor) if value in (None, '') and not self.display_false: return self.placeholder return render_to_string(self.template_name, { @@ -128,7 +120,7 @@ class ColorAttr(Attr): def render(self, obj, context=None): context = context or {} - value = self._resolve_attr(obj, self.accessor) + value = resolve_attr_path(obj, self.accessor) return render_to_string(self.template_name, { **context, 'color': value, @@ -140,7 +132,7 @@ class ImageAttr(Attr): def render(self, obj, context=None): context = context or {} - value = self._resolve_attr(obj, self.accessor) + value = resolve_attr_path(obj, self.accessor) if value in (None, ''): return self.placeholder return render_to_string(self.template_name, { @@ -159,7 +151,7 @@ class ObjectAttr(Attr): def render(self, obj, context=None): context = context or {} - value = self._resolve_attr(obj, self.accessor) + value = resolve_attr_path(obj, self.accessor) if value is None: return self.placeholder group = getattr(value, self.grouped_by, None) if self.grouped_by else None @@ -182,7 +174,7 @@ class NestedObjectAttr(Attr): def render(self, obj, context=None): context = context or {} - value = self._resolve_attr(obj, self.accessor) + value = resolve_attr_path(obj, self.accessor) if value is None: return self.placeholder nodes = value.get_ancestors(include_self=True) @@ -209,7 +201,7 @@ class AddressAttr(Attr): def render(self, obj, context=None): context = context or {} - value = self._resolve_attr(obj, self.accessor) + value = resolve_attr_path(obj, self.accessor) if value in (None, ''): return self.placeholder return render_to_string(self.template_name, { @@ -236,8 +228,8 @@ class GPSCoordinatesAttr(Attr): def render(self, obj, context=None): context = context or {} - latitude = self._resolve_attr(obj, self.latitude_attr) - longitude = self._resolve_attr(obj, self.longitude_attr) + latitude = resolve_attr_path(obj, self.latitude_attr) + longitude = resolve_attr_path(obj, self.longitude_attr) if latitude is None or longitude is None: return self.placeholder return render_to_string(self.template_name, { @@ -253,7 +245,7 @@ class TimezoneAttr(Attr): def render(self, obj, context=None): context = context or {} - value = self._resolve_attr(obj, self.accessor) + value = resolve_attr_path(obj, self.accessor) if value in (None, ''): return self.placeholder return render_to_string(self.template_name, { @@ -270,7 +262,7 @@ class TemplatedAttr(Attr): def render(self, obj, context=None): context = context or {} - value = self._resolve_attr(obj, self.accessor) + value = resolve_attr_path(obj, self.accessor) if value is None: return self.placeholder return render_to_string( @@ -289,7 +281,7 @@ class UtilizationAttr(Attr): def render(self, obj, context=None): context = context or {} - value = self._resolve_attr(obj, self.accessor) + value = resolve_attr_path(obj, self.accessor) return render_to_string(self.template_name, { **context, 'value': value, diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index f339d77b0..970a4fd73 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -6,6 +6,7 @@ from django.utils.translation import gettext_lazy as _ from netbox.ui import attrs from netbox.ui.actions import CopyContent +from utilities.data import resolve_attr_path from utilities.querydict import dict_to_querydict from utilities.string import title from utilities.templatetags.plugins import _get_registered_content @@ -15,6 +16,7 @@ __all__ = ( 'CommentsPanel', 'JSONPanel', 'NestedGroupObjectPanel', + 'ObjectAttributesPanel', 'ObjectPanel', 'ObjectsTablePanel', 'OrganizationalObjectPanel', @@ -25,6 +27,10 @@ __all__ = ( ) +# +# Base classes +# + class Panel(ABC): """ A block of content rendered within an HTML template. @@ -74,7 +80,44 @@ class Panel(ABC): return render_to_string(self.template_name, self.get_context(context)) -class ObjectPanelMeta(ABCMeta): +# +# Object-specific panels +# + +class ObjectPanel(Panel): + """ + Base class for object-specific panels. + """ + accessor = 'object' + + def __init__(self, accessor=None, **kwargs): + """ + Instantiate a new ObjectPanel. + + Parameters: + accessor: The name of the attribute on the object (default: "object") + """ + super().__init__(**kwargs) + + if accessor is not None: + self.accessor = accessor + + def get_context(self, context): + """ + Return the context data to be used when rendering the panel. + + Parameters: + context: The template context + """ + obj = resolve_attr_path(context, self.accessor) + return { + **super().get_context(context), + 'title': self.title or title(obj._meta.verbose_name), + 'object': obj, + } + + +class ObjectAttributesPanelMeta(ABCMeta): def __new__(mcls, name, bases, namespace, **kwargs): declared = {} @@ -101,7 +144,7 @@ class ObjectPanelMeta(ABCMeta): return cls -class ObjectPanel(Panel, metaclass=ObjectPanelMeta): +class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta): """ A panel which displays selected attributes of an object. @@ -109,10 +152,9 @@ class ObjectPanel(Panel, metaclass=ObjectPanelMeta): template_name: The name of the template to render accessor: The name of the attribute on the object """ - template_name = 'ui/panels/object.html' - accessor = None + template_name = 'ui/panels/object_attributes.html' - def __init__(self, accessor=None, only=None, exclude=None, **kwargs): + def __init__(self, only=None, exclude=None, **kwargs): """ Instantiate a new ObjectPanel. @@ -123,9 +165,6 @@ class ObjectPanel(Panel, metaclass=ObjectPanelMeta): """ super().__init__(**kwargs) - if accessor is not None: - self.accessor = accessor - # Set included/excluded attributes if only is not None and exclude is not None: raise ValueError("only and exclude cannot both be specified.") @@ -155,21 +194,20 @@ class ObjectPanel(Panel, metaclass=ObjectPanelMeta): elif self.exclude: attr_names -= set(self.exclude) - obj = getattr(context['object'], self.accessor) if self.accessor else context['object'] + ctx = super().get_context(context) return { - **super().get_context(context), - 'title': self.title or title(obj._meta.verbose_name), + **ctx, 'attrs': [ { 'label': attr.label or self._name_to_label(name), - 'value': attr.render(obj, {'name': name}), + 'value': attr.render(ctx['object'], {'name': name}), } for name, attr in self._attrs.items() if name in attr_names ], } -class OrganizationalObjectPanel(ObjectPanel, metaclass=ObjectPanelMeta): +class OrganizationalObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttributesPanelMeta): """ An ObjectPanel with attributes common to OrganizationalModels. """ @@ -177,20 +215,82 @@ class OrganizationalObjectPanel(ObjectPanel, metaclass=ObjectPanelMeta): description = attrs.TextAttr('description', label=_('Description')) -class NestedGroupObjectPanel(OrganizationalObjectPanel, metaclass=ObjectPanelMeta): +class NestedGroupObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttributesPanelMeta): """ An ObjectPanel with attributes common to NestedGroupObjects. """ parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True) -class CommentsPanel(Panel): +class CommentsPanel(ObjectPanel): """ A panel which displays comments associated with an object. """ template_name = 'ui/panels/comments.html' title = _('Comments') + def __init__(self, field_name='comments', **kwargs): + """ + Instantiate a new CommentsPanel. + + Parameters: + field_name: The name of the comment field on the object + """ + super().__init__(**kwargs) + self.field_name = field_name + + def get_context(self, context): + """ + Return the context data to be used when rendering the panel. + + Parameters: + context: The template context + """ + return { + **super().get_context(context), + 'comments': getattr(context['object'], self.field_name), + } + + +class JSONPanel(ObjectPanel): + """ + A panel which renders formatted JSON data from an object's JSONField. + """ + template_name = 'ui/panels/json.html' + + def __init__(self, field_name, copy_button=True, **kwargs): + """ + Instantiate a new JSONPanel. + + Parameters: + field_name: The name of the JSON field on the object + copy_button: Set to True (default) to include a copy-to-clipboard button + """ + super().__init__(**kwargs) + self.field_name = field_name + + if copy_button: + self.actions.append( + CopyContent(f'panel_{field_name}'), + ) + + def get_context(self, context): + """ + Return the context data to be used when rendering the panel. + + Parameters: + context: The template context + """ + return { + **super().get_context(context), + 'data': getattr(context['object'], self.field_name), + 'field_name': self.field_name, + } + + +# +# Miscellaneous panels +# class RelatedObjectsPanel(Panel): """ @@ -261,42 +361,6 @@ class ObjectsTablePanel(Panel): } -class JSONPanel(Panel): - """ - A panel which renders formatted JSON data. - """ - template_name = 'ui/panels/json.html' - - def __init__(self, field_name, copy_button=True, **kwargs): - """ - Instantiate a new JSONPanel. - - Parameters: - field_name: The name of the JSON field on the object - copy_button: Set to True (default) to include a copy-to-clipboard button - """ - super().__init__(**kwargs) - self.field_name = field_name - - if copy_button: - self.actions.append( - CopyContent(f'panel_{field_name}'), - ) - - def get_context(self, context): - """ - Return the context data to be used when rendering the panel. - - Parameters: - context: The template context - """ - return { - **super().get_context(context), - 'data': getattr(context['object'], self.field_name), - 'field_name': self.field_name, - } - - class TemplatePanel(Panel): """ A panel which renders content using an HTML template. diff --git a/netbox/templates/ui/panels/comments.html b/netbox/templates/ui/panels/comments.html index d5f07a8cc..de32162ce 100644 --- a/netbox/templates/ui/panels/comments.html +++ b/netbox/templates/ui/panels/comments.html @@ -3,8 +3,8 @@ {% block panel_content %}
- {% if object.comments %} - {{ object.comments|markdown }} + {% if comments %} + {{ comments|markdown }} {% else %} {% trans "None" %} {% endif %} diff --git a/netbox/templates/ui/panels/object.html b/netbox/templates/ui/panels/object_attributes.html similarity index 100% rename from netbox/templates/ui/panels/object.html rename to netbox/templates/ui/panels/object_attributes.html diff --git a/netbox/utilities/data.py b/netbox/utilities/data.py index 617a31cd6..8bd5dcbc6 100644 --- a/netbox/utilities/data.py +++ b/netbox/utilities/data.py @@ -12,6 +12,7 @@ __all__ = ( 'flatten_dict', 'ranges_to_string', 'ranges_to_string_list', + 'resolve_attr_path', 'shallow_compare_dict', 'string_to_ranges', ) @@ -213,3 +214,23 @@ def string_to_ranges(value): return None values.append(NumericRange(int(lower), int(upper) + 1, bounds='[)')) return values + + +# +# Attribute resolution +# + +def resolve_attr_path(obj, path): + """ + Follow a dotted path across attributes and/or dictionary keys and return the final value. + + Parameters: + obj: The starting object + path: The dotted path to follow (e.g. "foo.bar.baz") + """ + cur = obj + for part in path.split('.'): + if cur is None: + return None + cur = getattr(cur, part) if hasattr(cur, part) else cur.get(part) + return cur From 9d6522c11ebe08184776ec21e3940fcb0df993f5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Nov 2025 14:49:36 -0500 Subject: [PATCH 067/125] RackType has no airflow attribute --- netbox/dcim/ui/panels.py | 1 - netbox/utilities/data.py | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index 93043a005..ffa85e90d 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -69,7 +69,6 @@ class RackTypePanel(panels.ObjectAttributesPanel): manufacturer = attrs.ObjectAttr('manufacturer', linkify=True) model = attrs.TextAttr('model') description = attrs.TextAttr('description') - airflow = attrs.ChoiceAttr('airflow') class DevicePanel(panels.ObjectAttributesPanel): diff --git a/netbox/utilities/data.py b/netbox/utilities/data.py index 8bd5dcbc6..36fd0f7fc 100644 --- a/netbox/utilities/data.py +++ b/netbox/utilities/data.py @@ -232,5 +232,8 @@ def resolve_attr_path(obj, path): for part in path.split('.'): if cur is None: return None - cur = getattr(cur, part) if hasattr(cur, part) else cur.get(part) + try: + cur = getattr(cur, part) if hasattr(cur, part) else cur.get(part) + except AttributeError: + cur = None return cur From dfb08ff521402f4a9e120f9635e85fb5bcce662f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Nov 2025 15:08:51 -0500 Subject: [PATCH 068/125] Split PanelAction into a base class and LinkAction; CopyContent should inherit from base class --- netbox/extras/ui/panels.py | 4 +- netbox/netbox/ui/actions.py | 98 ++++++++++++++++++++++++++----------- netbox/netbox/ui/panels.py | 2 +- 3 files changed, 73 insertions(+), 31 deletions(-) diff --git a/netbox/extras/ui/panels.py b/netbox/extras/ui/panels.py index 991a4aa3d..4ab50ded7 100644 --- a/netbox/extras/ui/panels.py +++ b/netbox/extras/ui/panels.py @@ -10,7 +10,7 @@ __all__ = ( ) -class CustomFieldsPanel(panels.Panel): +class CustomFieldsPanel(panels.ObjectPanel): template_name = 'ui/panels/custom_fields.html' title = _('Custom Fields') @@ -39,7 +39,7 @@ class ImageAttachmentsPanel(panels.ObjectsTablePanel): super().__init__('extras.imageattachment', **kwargs) -class TagsPanel(panels.Panel): +class TagsPanel(panels.ObjectPanel): template_name = 'ui/panels/tags.html' title = _('Tags') diff --git a/netbox/netbox/ui/actions.py b/netbox/netbox/ui/actions.py index d0a374f58..2dd8482b2 100644 --- a/netbox/netbox/ui/actions.py +++ b/netbox/netbox/ui/actions.py @@ -25,13 +25,63 @@ class PanelAction: button_class: Bootstrap CSS class for the button button_icon: Name of the button's MDI icon """ - template_name = 'ui/actions/link.html' + template_name = None label = None button_class = 'primary' button_icon = None - # TODO: Refactor URL parameters to AddObject - def __init__(self, view_name, view_kwargs=None, url_params=None, permissions=None, label=None): + def __init__(self, label=None, permissions=None): + """ + Initialize a new PanelAction. + + Parameters: + label: The human-friendly button text + permissions: A list of permissions required to display the action + """ + if label is not None: + self.label = label + self.permissions = permissions + + def get_context(self, context): + """ + Return the template context used to render the action element. + + Parameters: + context: The template context + """ + return { + 'label': self.label, + 'button_class': self.button_class, + 'button_icon': self.button_icon, + } + + def render(self, context): + """ + Render the action as HTML. + + Parameters: + context: The template context + """ + # Enforce permissions + user = context['request'].user + if not user.has_perms(self.permissions): + return '' + + return render_to_string(self.template_name, self.get_context(context)) + + +class LinkAction(PanelAction): + """ + A hyperlink (typically a button) within a panel to perform some associated action, such as adding an object. + + Attributes: + label: The default human-friendly button text + button_class: Bootstrap CSS class for the button + button_icon: Name of the button's MDI icon + """ + template_name = 'ui/actions/link.html' + + def __init__(self, view_name, view_kwargs=None, url_params=None, **kwargs): """ Initialize a new PanelAction. @@ -42,12 +92,11 @@ class PanelAction: permissions: A list of permissions required to display the action label: The human-friendly button text """ + super().__init__(**kwargs) + self.view_name = view_name self.view_kwargs = view_kwargs or {} self.url_params = url_params or {} - self.permissions = permissions - if label is not None: - self.label = label def get_url(self, context): """ @@ -68,27 +117,14 @@ class PanelAction: url = f'{url}?{urlencode(url_params)}' return url - def render(self, context): - """ - Render the action as HTML. - - Parameters: - context: The template context - """ - # Enforce permissions - user = context['request'].user - if not user.has_perms(self.permissions): - return '' - - return render_to_string(self.template_name, { + def get_context(self, context): + return { + **super().get_context(context), 'url': self.get_url(context), - 'label': self.label, - 'button_class': self.button_class, - 'button_icon': self.button_icon, - }) + } -class AddObject(PanelAction): +class AddObject(LinkAction): """ An action to add a new object. """ @@ -112,22 +148,28 @@ class AddObject(PanelAction): raise ValueError(f"Invalid model label: {model}") view_name = get_viewname(model, 'add') - super().__init__(view_name=view_name, label=label, url_params=url_params) + super().__init__(view_name=view_name, url_params=url_params, label=label) # Require "add" permission on the model self.permissions = [get_permission_for_model(model, 'add')] -class CopyContent: +class CopyContent(PanelAction): """ An action to copy the contents of a panel to the clipboard. """ template_name = 'ui/actions/copy_content.html' label = _('Copy') - button_class = 'primary' button_icon = 'content-copy' - def __init__(self, target_id): + def __init__(self, target_id, **kwargs): + """ + Instantiate a new CopyContent action. + + Parameters: + target_id: The ID of the target element containing the content to be copied + """ + super().__init__(**kwargs) self.target_id = target_id def render(self, context): diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index 970a4fd73..b20d8fed5 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -54,7 +54,7 @@ class Panel(ABC): """ if title is not None: self.title = title - self.actions = actions or [] + self.actions = actions or self.actions or [] def get_context(self, context): """ From 4edaa48aa75b58f393900aa783ae09bef24db886 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Nov 2025 15:51:36 -0500 Subject: [PATCH 069/125] Refactor render() on Attr to split out context and reduce boilerplate --- netbox/netbox/ui/attrs.py | 235 +++++++++++--------------- netbox/netbox/ui/panels.py | 4 +- netbox/templates/ui/attrs/color.html | 2 +- netbox/templates/ui/attrs/object.html | 4 +- 4 files changed, 105 insertions(+), 140 deletions(-) diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index 1300b4702..7c683b0f7 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -1,5 +1,3 @@ -from abc import ABC, abstractmethod - from django.template.loader import render_to_string from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ @@ -12,23 +10,65 @@ from utilities.data import resolve_attr_path # Attributes # -class Attr(ABC): +class ObjectAttribute: + """ + Base class for representing an attribute of an object. + + Attributes: + template_name: The name of the template to render + label: Human-friendly label for the rendered attribute + placeholder: HTML to render for empty/null values + """ template_name = None label = None placeholder = mark_safe('') def __init__(self, accessor, label=None, template_name=None): + """ + Instantiate a new ObjectAttribute. + + Parameters: + accessor: The dotted path to the attribute being rendered (e.g. "site.region.name") + label: Human-friendly label for the rendered attribute + template_name: The name of the template to render + """ self.accessor = accessor - self.template_name = template_name or self.template_name + if template_name is not None: + self.template_name = template_name if label is not None: self.label = label - @abstractmethod - def render(self, obj, context=None): - pass + def get_value(self, obj): + """ + Return the value of the attribute. + + Parameters: + obj: The object for which the attribute is being rendered + """ + return resolve_attr_path(obj, self.accessor) + + def get_context(self, obj, context): + """ + Return any additional template context used to render the attribute value. + + Parameters: + obj: The object for which the attribute is being rendered + context: The template context + """ + return {} + + def render(self, obj, context): + value = self.get_value(obj) + if value in (None, ''): + return self.placeholder + context = self.get_context(obj, context) + return render_to_string(self.template_name, { + **context, + 'value': value, + }) -class TextAttr(Attr): +class TextAttr(ObjectAttribute): template_name = 'ui/attrs/text.html' def __init__(self, *args, style=None, format_string=None, copy_button=False, **kwargs): @@ -37,22 +77,21 @@ class TextAttr(Attr): self.format_string = format_string self.copy_button = copy_button - def render(self, obj, context=None): - context = context or {} + def get_value(self, obj): value = resolve_attr_path(obj, self.accessor) - if value in (None, ''): - return self.placeholder - if self.format_string: + # Apply format string (if any) + if value and self.format_string: value = self.format_string.format(value) - return render_to_string(self.template_name, { - **context, - 'value': value, + return value + + def get_context(self, obj, context): + return { 'style': self.style, 'copy_button': self.copy_button, - }) + } -class NumericAttr(Attr): +class NumericAttr(ObjectAttribute): template_name = 'ui/attrs/numeric.html' def __init__(self, *args, unit_accessor=None, copy_button=False, **kwargs): @@ -60,88 +99,57 @@ class NumericAttr(Attr): self.unit_accessor = unit_accessor self.copy_button = copy_button - def render(self, obj, context=None): - context = context or {} - value = resolve_attr_path(obj, self.accessor) - if value in (None, ''): - return self.placeholder + def get_context(self, obj, context): unit = resolve_attr_path(obj, self.unit_accessor) if self.unit_accessor else None - return render_to_string(self.template_name, { - **context, - 'value': value, + return { 'unit': unit, 'copy_button': self.copy_button, - }) + } -class ChoiceAttr(Attr): +class ChoiceAttr(ObjectAttribute): template_name = 'ui/attrs/choice.html' - def render(self, obj, context=None): - context = context or {} + def get_value(self, obj): try: - value = getattr(obj, f'get_{self.accessor}_display')() + return getattr(obj, f'get_{self.accessor}_display')() except AttributeError: - value = resolve_attr_path(obj, self.accessor) - if value in (None, ''): - return self.placeholder + return resolve_attr_path(obj, self.accessor) + + def get_context(self, obj, context): try: bg_color = getattr(obj, f'get_{self.accessor}_color')() except AttributeError: bg_color = None - return render_to_string(self.template_name, { - **context, - 'value': value, + return { 'bg_color': bg_color, - }) + } -class BooleanAttr(Attr): +class BooleanAttr(ObjectAttribute): template_name = 'ui/attrs/boolean.html' def __init__(self, *args, display_false=True, **kwargs): super().__init__(*args, **kwargs) self.display_false = display_false - def render(self, obj, context=None): - context = context or {} - value = resolve_attr_path(obj, self.accessor) - if value in (None, '') and not self.display_false: - return self.placeholder - return render_to_string(self.template_name, { - **context, - 'value': value, - }) + def get_value(self, obj): + value = super().get_value(obj) + if value is False and self.display_false is False: + return None + return value -class ColorAttr(Attr): +class ColorAttr(ObjectAttribute): template_name = 'ui/attrs/color.html' label = _('Color') - def render(self, obj, context=None): - context = context or {} - value = resolve_attr_path(obj, self.accessor) - return render_to_string(self.template_name, { - **context, - 'color': value, - }) - -class ImageAttr(Attr): +class ImageAttr(ObjectAttribute): template_name = 'ui/attrs/image.html' - def render(self, obj, context=None): - context = context or {} - value = resolve_attr_path(obj, self.accessor) - if value in (None, ''): - return self.placeholder - return render_to_string(self.template_name, { - **context, - 'value': value, - }) - -class ObjectAttr(Attr): +class ObjectAttr(ObjectAttribute): template_name = 'ui/attrs/object.html' def __init__(self, *args, linkify=None, grouped_by=None, **kwargs): @@ -149,22 +157,16 @@ class ObjectAttr(Attr): self.linkify = linkify self.grouped_by = grouped_by - def render(self, obj, context=None): - context = context or {} - value = resolve_attr_path(obj, self.accessor) - if value is None: - return self.placeholder + def get_context(self, obj, context): + value = self.get_value(obj) group = getattr(value, self.grouped_by, None) if self.grouped_by else None - - return render_to_string(self.template_name, { - **context, - 'object': value, - 'group': group, + return { 'linkify': self.linkify, - }) + 'group': group, + } -class NestedObjectAttr(Attr): +class NestedObjectAttr(ObjectAttribute): template_name = 'ui/attrs/nested_object.html' def __init__(self, *args, linkify=None, max_depth=None, **kwargs): @@ -172,22 +174,18 @@ class NestedObjectAttr(Attr): self.linkify = linkify self.max_depth = max_depth - def render(self, obj, context=None): - context = context or {} - value = resolve_attr_path(obj, self.accessor) - if value is None: - return self.placeholder + def get_context(self, obj, context): + value = self.get_value(obj) nodes = value.get_ancestors(include_self=True) if self.max_depth: nodes = list(nodes)[-self.max_depth:] - return render_to_string(self.template_name, { - **context, + return { 'nodes': nodes, 'linkify': self.linkify, - }) + } -class AddressAttr(Attr): +class AddressAttr(ObjectAttribute): template_name = 'ui/attrs/address.html' def __init__(self, *args, map_url=True, **kwargs): @@ -199,19 +197,13 @@ class AddressAttr(Attr): else: self.map_url = None - def render(self, obj, context=None): - context = context or {} - value = resolve_attr_path(obj, self.accessor) - if value in (None, ''): - return self.placeholder - return render_to_string(self.template_name, { - **context, - 'value': value, + def get_context(self, obj, context): + return { 'map_url': self.map_url, - }) + } -class GPSCoordinatesAttr(Attr): +class GPSCoordinatesAttr(ObjectAttribute): template_name = 'ui/attrs/gps_coordinates.html' label = _('GPS coordinates') @@ -240,49 +232,22 @@ class GPSCoordinatesAttr(Attr): }) -class TimezoneAttr(Attr): +class TimezoneAttr(ObjectAttribute): template_name = 'ui/attrs/timezone.html' - def render(self, obj, context=None): - context = context or {} - value = resolve_attr_path(obj, self.accessor) - if value in (None, ''): - return self.placeholder - return render_to_string(self.template_name, { - **context, - 'value': value, - }) - -class TemplatedAttr(Attr): +class TemplatedAttr(ObjectAttribute): def __init__(self, *args, context=None, **kwargs): super().__init__(*args, **kwargs) self.context = context or {} - def render(self, obj, context=None): - context = context or {} - value = resolve_attr_path(obj, self.accessor) - if value is None: - return self.placeholder - return render_to_string( - self.template_name, - { - **context, - **self.context, - 'object': obj, - 'value': value, - } - ) + def get_context(self, obj, context): + return { + **self.context, + 'object': obj, + } -class UtilizationAttr(Attr): +class UtilizationAttr(ObjectAttribute): template_name = 'ui/attrs/utilization.html' - - def render(self, obj, context=None): - context = context or {} - value = resolve_attr_path(obj, self.accessor) - return render_to_string(self.template_name, { - **context, - 'value': value, - }) diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index b20d8fed5..5827bb6b0 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -130,13 +130,13 @@ class ObjectAttributesPanelMeta(ABCMeta): # Add local declarations in the order they appear in the class body for key, attr in namespace.items(): - if isinstance(attr, attrs.Attr): + if isinstance(attr, attrs.ObjectAttribute): declared[key] = attr namespace['_attrs'] = declared # Remove Attrs from the class namespace to keep things tidy - local_items = [key for key, attr in namespace.items() if isinstance(attr, attrs.Attr)] + local_items = [key for key, attr in namespace.items() if isinstance(attr, attrs.ObjectAttribute)] for key in local_items: namespace.pop(key) diff --git a/netbox/templates/ui/attrs/color.html b/netbox/templates/ui/attrs/color.html index 29d11207a..78e1cfff3 100644 --- a/netbox/templates/ui/attrs/color.html +++ b/netbox/templates/ui/attrs/color.html @@ -1 +1 @@ -  +  diff --git a/netbox/templates/ui/attrs/object.html b/netbox/templates/ui/attrs/object.html index 55263138b..58fce2316 100644 --- a/netbox/templates/ui/attrs/object.html +++ b/netbox/templates/ui/attrs/object.html @@ -5,10 +5,10 @@ {% if linkify %}{{ group|linkify }}{% else %}{{ group }}{% endif %} {% else %} {# Display only the object #} - {% if linkify %}{{ object|linkify }}{% else %}{{ object }}{% endif %} + {% if linkify %}{{ value|linkify }}{% else %}{{ value }}{% endif %} {% endif %} From 1d2aef71b22f7cefd4db109001d4798a774e8237 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Nov 2025 15:56:12 -0500 Subject: [PATCH 070/125] Hide custom fields panels if no custom fields exist on the model --- netbox/extras/ui/panels.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/netbox/extras/ui/panels.py b/netbox/extras/ui/panels.py index 4ab50ded7..4cad3c76c 100644 --- a/netbox/extras/ui/panels.py +++ b/netbox/extras/ui/panels.py @@ -1,4 +1,5 @@ from django.contrib.contenttypes.models import ContentType +from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ from netbox.ui import actions, panels @@ -11,6 +12,9 @@ __all__ = ( class CustomFieldsPanel(panels.ObjectPanel): + """ + Render a panel showing the value of all custom fields defined on the object. + """ template_name = 'ui/panels/custom_fields.html' title = _('Custom Fields') @@ -21,8 +25,18 @@ class CustomFieldsPanel(panels.ObjectPanel): 'custom_fields': obj.get_custom_fields_by_group(), } + def render(self, context): + ctx = self.get_context(context) + # Hide the panel if no custom fields exist + if not ctx['custom_fields']: + return '' + return render_to_string(self.template_name, self.get_context(context)) + class ImageAttachmentsPanel(panels.ObjectsTablePanel): + """ + Render a table listing all images attached to the object. + """ actions = [ actions.AddObject( 'extras.imageattachment', @@ -40,6 +54,9 @@ class ImageAttachmentsPanel(panels.ObjectsTablePanel): class TagsPanel(panels.ObjectPanel): + """ + Render a panel showing the tags assigned to the object. + """ template_name = 'ui/panels/tags.html' title = _('Tags') From e9777d3193d46148c7ebf47ac817354e39d52f7e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Nov 2025 16:56:53 -0500 Subject: [PATCH 071/125] Flesh out device layout --- netbox/dcim/ui/panels.py | 32 ++++++++++++++-- netbox/dcim/views.py | 38 +++++++++++++++++++ .../dcim/device/attrs/total_weight.html | 3 ++ .../dcim/panels/virtual_chassis_members.html | 31 +++++++++++++++ 4 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 netbox/templates/dcim/device/attrs/total_weight.html create mode 100644 netbox/templates/dcim/panels/virtual_chassis_members.html diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index ffa85e90d..e4845bfc0 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -26,7 +26,7 @@ class LocationPanel(panels.NestedGroupObjectPanel): class RackDimensionsPanel(panels.ObjectAttributesPanel): form_factor = attrs.ChoiceAttr('form_factor') width = attrs.ChoiceAttr('width') - u_height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height')) + height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height')) outer_width = attrs.NumericAttr('outer_width', unit_accessor='get_outer_unit_display') outer_height = attrs.NumericAttr('outer_height', unit_accessor='get_outer_unit_display') outer_depth = attrs.NumericAttr('outer_depth', unit_accessor='get_outer_unit_display') @@ -76,7 +76,7 @@ class DevicePanel(panels.ObjectAttributesPanel): site = attrs.ObjectAttr('site', linkify=True, grouped_by='group') location = attrs.NestedObjectAttr('location', linkify=True) rack = attrs.TemplatedAttr('rack', template_name='dcim/device/attrs/rack.html') - virtual_chassis = attrs.NestedObjectAttr('virtual_chassis', linkify=True) + virtual_chassis = attrs.ObjectAttr('virtual_chassis', linkify=True) parent_device = attrs.TemplatedAttr('parent_bay', template_name='dcim/device/attrs/parent_device.html') gps_coordinates = attrs.GPSCoordinatesAttr() tenant = attrs.ObjectAttr('tenant', linkify=True, grouped_by='group') @@ -107,6 +107,12 @@ class DeviceManagementPanel(panels.ObjectAttributesPanel): label=_('Out-of-band IP'), template_name='dcim/device/attrs/ipaddress.html', ) + cluster = attrs.ObjectAttr('cluster', linkify=True) + + +class DeviceDimensionsPanel(panels.ObjectAttributesPanel): + height = attrs.TextAttr('device_type.u_height', format_string='{}U') + total_weight = attrs.TemplatedAttr('total_weight', template_name='dcim/device/attrs/total_weight.html') class DeviceTypePanel(panels.ObjectAttributesPanel): @@ -115,7 +121,7 @@ class DeviceTypePanel(panels.ObjectAttributesPanel): part_number = attrs.TextAttr('part_number') default_platform = attrs.ObjectAttr('default_platform', linkify=True) description = attrs.TextAttr('description') - u_height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height')) + height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height')) exclude_from_utilization = attrs.BooleanAttr('exclude_from_utilization') full_depth = attrs.BooleanAttr('is_full_depth') weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display') @@ -128,3 +134,23 @@ class DeviceTypePanel(panels.ObjectAttributesPanel): class ModuleTypeProfilePanel(panels.ObjectAttributesPanel): name = attrs.TextAttr('name') description = attrs.TextAttr('description') + + +class VirtualChassisMembersPanel(panels.ObjectPanel): + """ + A panel which lists all members of a virtual chassis. + """ + template_name = 'dcim/panels/virtual_chassis_members.html' + title = _('Virtual Chassis Members') + + def get_context(self, context): + """ + Return the context data to be used when rendering the panel. + + Parameters: + context: The template context + """ + return { + **super().get_context(context), + 'vc_members': context.get('vc_members'), + } diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 051d2867d..9f7b3f06a 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2439,6 +2439,44 @@ class DeviceListView(generic.ObjectListView): @register_model_view(Device) class DeviceView(generic.ObjectView): queryset = Device.objects.all() + layout = layout.SimpleLayout( + left_panels=[ + panels.DevicePanel(), + panels.VirtualChassisMembersPanel(), + CustomFieldsPanel(), + TagsPanel(), + CommentsPanel(), + ObjectsTablePanel( + model='dcim.VirtualDeviceContext', + filters={'device_id': lambda ctx: ctx['object'].pk}, + actions=[ + actions.AddObject('dcim.VirtualDeviceContext', url_params={'device': lambda ctx: ctx['object'].pk}), + ], + ), + ], + right_panels=[ + panels.DeviceManagementPanel(), + # TODO: Power utilization + ObjectsTablePanel( + model='ipam.Service', + title=_('Application Services'), + filters={'device_id': lambda ctx: ctx['object'].pk}, + actions=[ + 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 + } + ), + ], + ), + ImageAttachmentsPanel(), + panels.DeviceDimensionsPanel(title=_('Dimensions')), + # TODO: Rack elevations + # TemplatePanel('dcim/panels/rack_elevations.html'), + ], + ) def get_extra_context(self, request, instance): # VirtualChassis members diff --git a/netbox/templates/dcim/device/attrs/total_weight.html b/netbox/templates/dcim/device/attrs/total_weight.html new file mode 100644 index 000000000..73ac54ef5 --- /dev/null +++ b/netbox/templates/dcim/device/attrs/total_weight.html @@ -0,0 +1,3 @@ +{% load helpers i18n %} +{{ value|floatformat }} {% trans "Kilograms" %} +({{ value|kg_to_pounds|floatformat }} {% trans "Pounds" %}) diff --git a/netbox/templates/dcim/panels/virtual_chassis_members.html b/netbox/templates/dcim/panels/virtual_chassis_members.html new file mode 100644 index 000000000..29e422ea6 --- /dev/null +++ b/netbox/templates/dcim/panels/virtual_chassis_members.html @@ -0,0 +1,31 @@ +{% extends "ui/panels/_base.html" %} +{% load i18n %} + +{% block panel_content %} + + + + + + + + + + + {% for vc_member in vc_members %} + + + + + + + {% endfor %} + +
{% trans "Device" %}{% trans "Position" %}{% trans "Master" %}{% trans "Priority" %}
{{ vc_member|linkify }}{% badge vc_member.vc_position show_empty=True %} + {% if object.virtual_chassis.master == vc_member %} + {% checkmark True %} + {% else %} + {{ ''|placeholder }} + {% endif %} + {{ vc_member.vc_priority|placeholder }}
+{% endblock panel_content %} From 60cc009d6b47afd45dbc722f7c47b87f7a07a657 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 6 Nov 2025 12:04:15 -0500 Subject: [PATCH 072/125] Move templates for extras panels --- netbox/extras/ui/panels.py | 4 ++-- netbox/templates/{ui => extras}/panels/custom_fields.html | 0 netbox/templates/{ui => extras}/panels/tags.html | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename netbox/templates/{ui => extras}/panels/custom_fields.html (100%) rename netbox/templates/{ui => extras}/panels/tags.html (100%) diff --git a/netbox/extras/ui/panels.py b/netbox/extras/ui/panels.py index 4cad3c76c..2ab55ecd8 100644 --- a/netbox/extras/ui/panels.py +++ b/netbox/extras/ui/panels.py @@ -15,7 +15,7 @@ class CustomFieldsPanel(panels.ObjectPanel): """ Render a panel showing the value of all custom fields defined on the object. """ - template_name = 'ui/panels/custom_fields.html' + template_name = 'extras/panels/custom_fields.html' title = _('Custom Fields') def get_context(self, context): @@ -57,7 +57,7 @@ class TagsPanel(panels.ObjectPanel): """ Render a panel showing the tags assigned to the object. """ - template_name = 'ui/panels/tags.html' + template_name = 'extras/panels/tags.html' title = _('Tags') def get_context(self, context): diff --git a/netbox/templates/ui/panels/custom_fields.html b/netbox/templates/extras/panels/custom_fields.html similarity index 100% rename from netbox/templates/ui/panels/custom_fields.html rename to netbox/templates/extras/panels/custom_fields.html diff --git a/netbox/templates/ui/panels/tags.html b/netbox/templates/extras/panels/tags.html similarity index 100% rename from netbox/templates/ui/panels/tags.html rename to netbox/templates/extras/panels/tags.html From e55a4ae603f193b96fb844b4f2ab69deb595aca5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 6 Nov 2025 12:31:20 -0500 Subject: [PATCH 073/125] Finish layout for device view --- netbox/dcim/ui/panels.py | 35 ++++++++++--- netbox/dcim/views.py | 7 ++- .../dcim/panels/device_rack_elevations.html | 26 ++++++++++ .../dcim/panels/power_utilization.html | 50 +++++++++++++++++++ 4 files changed, 108 insertions(+), 10 deletions(-) create mode 100644 netbox/templates/dcim/panels/device_rack_elevations.html create mode 100644 netbox/templates/dcim/panels/power_utilization.html diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index e4845bfc0..62435bedf 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -89,6 +89,8 @@ class DevicePanel(panels.ObjectAttributesPanel): class DeviceManagementPanel(panels.ObjectAttributesPanel): + title = _('Management') + status = attrs.ChoiceAttr('status') role = attrs.NestedObjectAttr('role', linkify=True, max_depth=3) platform = attrs.NestedObjectAttr('platform', linkify=True, max_depth=3) @@ -111,6 +113,8 @@ class DeviceManagementPanel(panels.ObjectAttributesPanel): class DeviceDimensionsPanel(panels.ObjectAttributesPanel): + title = _('Dimensions') + height = attrs.TextAttr('device_type.u_height', format_string='{}U') total_weight = attrs.TemplatedAttr('total_weight', template_name='dcim/device/attrs/total_weight.html') @@ -144,13 +148,32 @@ class VirtualChassisMembersPanel(panels.ObjectPanel): title = _('Virtual Chassis Members') def get_context(self, context): - """ - Return the context data to be used when rendering the panel. - - Parameters: - context: The template context - """ return { **super().get_context(context), 'vc_members': context.get('vc_members'), } + + def render(self, context): + if not context.get('vc_members'): + return '' + return super().render(context) + + +class PowerUtilizationPanel(panels.ObjectPanel): + """ + A panel which displays the power utilization statistics for a device. + """ + template_name = 'dcim/panels/power_utilization.html' + title = _('Power Utilization') + + def get_context(self, context): + return { + **super().get_context(context), + 'vc_members': context.get('vc_members'), + } + + def render(self, context): + obj = context['object'] + if not obj.powerports.exists() or not obj.poweroutlets.exists(): + return '' + return super().render(context) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 9f7b3f06a..b8a9e134a 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2456,7 +2456,7 @@ class DeviceView(generic.ObjectView): ], right_panels=[ panels.DeviceManagementPanel(), - # TODO: Power utilization + panels.PowerUtilizationPanel(), ObjectsTablePanel( model='ipam.Service', title=_('Application Services'), @@ -2472,9 +2472,8 @@ class DeviceView(generic.ObjectView): ], ), ImageAttachmentsPanel(), - panels.DeviceDimensionsPanel(title=_('Dimensions')), - # TODO: Rack elevations - # TemplatePanel('dcim/panels/rack_elevations.html'), + panels.DeviceDimensionsPanel(), + TemplatePanel('dcim/panels/device_rack_elevations.html'), ], ) diff --git a/netbox/templates/dcim/panels/device_rack_elevations.html b/netbox/templates/dcim/panels/device_rack_elevations.html new file mode 100644 index 000000000..1816be5c9 --- /dev/null +++ b/netbox/templates/dcim/panels/device_rack_elevations.html @@ -0,0 +1,26 @@ +{% load i18n %} +{% if object.rack and object.position %} +
+
+ {{ object.rack.name }} + {% if object.rack.role %} +
{{ object.rack.role }} + {% endif %} + {% if object.rack.facility_id %} +
{{ object.rack.facility_id }} + {% endif %} +
+
+
+

{% trans "Front" %}

+ {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' extra_params=svg_extra %} +
+
+
+
+

{% trans "Rear" %}

+ {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' extra_params=svg_extra %} +
+
+
+{% endif %} diff --git a/netbox/templates/dcim/panels/power_utilization.html b/netbox/templates/dcim/panels/power_utilization.html new file mode 100644 index 000000000..b716ed2c9 --- /dev/null +++ b/netbox/templates/dcim/panels/power_utilization.html @@ -0,0 +1,50 @@ +{% extends "ui/panels/_base.html" %} +{% load helpers i18n %} + +{% block panel_content %} + + + + + + + + + + + {% for powerport in object.powerports.all %} + {% with utilization=powerport.get_power_draw powerfeed=powerport.connected_endpoints.0 %} + + + + + {% if powerfeed.available_power %} + + + {% else %} + + + {% endif %} + + {% for leg in utilization.legs %} + + + + + {% if powerfeed.available_power %} + {% with phase_available=powerfeed.available_power|divide:3 %} + + + {% endwith %} + {% else %} + + + {% endif %} + + {% endfor %} + {% endwith %} + {% endfor %} +
{% trans "Input" %}{% trans "Outlets" %}{% trans "Allocated" %}{% trans "Available" %}{% trans "Utilization" %}
{{ powerport }}{{ utilization.outlet_count }}{{ utilization.allocated }}{% trans "VA" %}{{ powerfeed.available_power }}{% trans "VA" %}{% utilization_graph utilization.allocated|percentage:powerfeed.available_power %}
+ {% trans "Leg" context "Leg of a power feed" %} {{ leg.name }} + {{ leg.outlet_count }}{{ leg.allocated }}{{ phase_available }}{% trans "VA" %}{% utilization_graph leg.allocated|percentage:phase_available %}
+{% endblock panel_content %} From 6fc04bd1fe823958b0d4949cdf86c5704575f139 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 6 Nov 2025 12:40:33 -0500 Subject: [PATCH 074/125] Fix accessor --- netbox/dcim/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index b8a9e134a..4cb5233e1 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1147,7 +1147,7 @@ class RackReservationView(generic.ObjectView): queryset = RackReservation.objects.all() layout = layout.SimpleLayout( left_panels=[ - panels.RackPanel(accessor='rack', only=['region', 'site', 'location']), + panels.RackPanel(accessor='object.rack', only=['region', 'site', 'location']), CustomFieldsPanel(), TagsPanel(), CommentsPanel(), From a024012abd3c440a32cd0cf9c9d80d67979ac354 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 6 Nov 2025 14:54:40 -0500 Subject: [PATCH 075/125] Misc cleanup --- netbox/dcim/ui/panels.py | 32 ++-- netbox/extras/ui/panels.py | 11 +- netbox/netbox/ui/attrs.py | 164 ++++++++++++++++-- netbox/netbox/ui/layout.py | 20 ++- netbox/netbox/ui/panels.py | 75 ++------ .../dcim/device/attrs/ipaddress.html | 1 - .../utilities/templatetags/builtins/tags.py | 3 + 7 files changed, 210 insertions(+), 96 deletions(-) diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index 62435bedf..4661e3151 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -7,7 +7,7 @@ class SitePanel(panels.ObjectAttributesPanel): region = attrs.NestedObjectAttr('region', linkify=True) group = attrs.NestedObjectAttr('group', linkify=True) status = attrs.ChoiceAttr('status') - tenant = attrs.ObjectAttr('tenant', linkify=True, grouped_by='group') + tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group') facility = attrs.TextAttr('facility') description = attrs.TextAttr('description') timezone = attrs.TimezoneAttr('time_zone') @@ -17,9 +17,9 @@ class SitePanel(panels.ObjectAttributesPanel): class LocationPanel(panels.NestedGroupObjectPanel): - site = attrs.ObjectAttr('site', linkify=True, grouped_by='group') + site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group') status = attrs.ChoiceAttr('status') - tenant = attrs.ObjectAttr('tenant', linkify=True, grouped_by='group') + tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group') facility = attrs.TextAttr('facility') @@ -40,13 +40,13 @@ class RackNumberingPanel(panels.ObjectAttributesPanel): class RackPanel(panels.ObjectAttributesPanel): region = attrs.NestedObjectAttr('site.region', linkify=True) - site = attrs.ObjectAttr('site', linkify=True, grouped_by='group') + site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group') location = attrs.NestedObjectAttr('location', linkify=True) facility = attrs.TextAttr('facility') - tenant = attrs.ObjectAttr('tenant', linkify=True, grouped_by='group') + tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group') status = attrs.ChoiceAttr('status') - rack_type = attrs.ObjectAttr('rack_type', linkify=True, grouped_by='manufacturer') - role = attrs.ObjectAttr('role', linkify=True) + rack_type = attrs.RelatedObjectAttr('rack_type', linkify=True, grouped_by='manufacturer') + role = attrs.RelatedObjectAttr('role', linkify=True) description = attrs.TextAttr('description') serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True) asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True) @@ -66,26 +66,26 @@ class RackRolePanel(panels.OrganizationalObjectPanel): class RackTypePanel(panels.ObjectAttributesPanel): - manufacturer = attrs.ObjectAttr('manufacturer', linkify=True) + manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True) model = attrs.TextAttr('model') description = attrs.TextAttr('description') class DevicePanel(panels.ObjectAttributesPanel): region = attrs.NestedObjectAttr('site.region', linkify=True) - site = attrs.ObjectAttr('site', linkify=True, grouped_by='group') + site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group') location = attrs.NestedObjectAttr('location', linkify=True) rack = attrs.TemplatedAttr('rack', template_name='dcim/device/attrs/rack.html') - virtual_chassis = attrs.ObjectAttr('virtual_chassis', linkify=True) + virtual_chassis = attrs.RelatedObjectAttr('virtual_chassis', linkify=True) parent_device = attrs.TemplatedAttr('parent_bay', template_name='dcim/device/attrs/parent_device.html') gps_coordinates = attrs.GPSCoordinatesAttr() - tenant = attrs.ObjectAttr('tenant', linkify=True, grouped_by='group') - device_type = attrs.ObjectAttr('device_type', linkify=True, grouped_by='manufacturer') + tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group') + device_type = attrs.RelatedObjectAttr('device_type', linkify=True, grouped_by='manufacturer') description = attrs.TextAttr('description') airflow = attrs.ChoiceAttr('airflow') serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True) asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True) - config_template = attrs.ObjectAttr('config_template', linkify=True) + config_template = attrs.RelatedObjectAttr('config_template', linkify=True) class DeviceManagementPanel(panels.ObjectAttributesPanel): @@ -109,7 +109,7 @@ class DeviceManagementPanel(panels.ObjectAttributesPanel): label=_('Out-of-band IP'), template_name='dcim/device/attrs/ipaddress.html', ) - cluster = attrs.ObjectAttr('cluster', linkify=True) + cluster = attrs.RelatedObjectAttr('cluster', linkify=True) class DeviceDimensionsPanel(panels.ObjectAttributesPanel): @@ -120,10 +120,10 @@ class DeviceDimensionsPanel(panels.ObjectAttributesPanel): class DeviceTypePanel(panels.ObjectAttributesPanel): - manufacturer = attrs.ObjectAttr('manufacturer', linkify=True) + manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True) model = attrs.TextAttr('model') part_number = attrs.TextAttr('part_number') - default_platform = attrs.ObjectAttr('default_platform', linkify=True) + default_platform = attrs.RelatedObjectAttr('default_platform', linkify=True) description = attrs.TextAttr('description') height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height')) exclude_from_utilization = attrs.BooleanAttr('exclude_from_utilization') diff --git a/netbox/extras/ui/panels.py b/netbox/extras/ui/panels.py index 2ab55ecd8..f2f9a5c9a 100644 --- a/netbox/extras/ui/panels.py +++ b/netbox/extras/ui/panels.py @@ -3,6 +3,7 @@ from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ from netbox.ui import actions, panels +from utilities.data import resolve_attr_path __all__ = ( 'CustomFieldsPanel', @@ -13,13 +14,13 @@ __all__ = ( class CustomFieldsPanel(panels.ObjectPanel): """ - Render a panel showing the value of all custom fields defined on the object. + A panel showing the value of all custom fields defined on an object. """ template_name = 'extras/panels/custom_fields.html' title = _('Custom Fields') def get_context(self, context): - obj = context['object'] + obj = resolve_attr_path(context, self.accessor) return { **super().get_context(context), 'custom_fields': obj.get_custom_fields_by_group(), @@ -35,7 +36,7 @@ class CustomFieldsPanel(panels.ObjectPanel): class ImageAttachmentsPanel(panels.ObjectsTablePanel): """ - Render a table listing all images attached to the object. + A panel showing all images attached to the object. """ actions = [ actions.AddObject( @@ -55,7 +56,7 @@ class ImageAttachmentsPanel(panels.ObjectsTablePanel): class TagsPanel(panels.ObjectPanel): """ - Render a panel showing the tags assigned to the object. + A panel showing the tags assigned to the object. """ template_name = 'extras/panels/tags.html' title = _('Tags') @@ -63,5 +64,5 @@ class TagsPanel(panels.ObjectPanel): def get_context(self, context): return { **super().get_context(context), - 'object': context['object'], + 'object': resolve_attr_path(context, self.accessor), } diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index 7c683b0f7..53e295246 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -5,6 +5,25 @@ from django.utils.translation import gettext_lazy as _ from netbox.config import get_config from utilities.data import resolve_attr_path +__all__ = ( + 'AddressAttr', + 'BooleanAttr', + 'ColorAttr', + 'ChoiceAttr', + 'GPSCoordinatesAttr', + 'ImageAttr', + 'NestedObjectAttr', + 'NumericAttr', + 'ObjectAttribute', + 'RelatedObjectAttr', + 'TemplatedAttr', + 'TextAttr', + 'TimezoneAttr', + 'UtilizationAttr', +) + +PLACEHOLDER_HTML = '' + # # Attributes @@ -21,20 +40,17 @@ class ObjectAttribute: """ template_name = None label = None - placeholder = mark_safe('') + placeholder = mark_safe(PLACEHOLDER_HTML) - def __init__(self, accessor, label=None, template_name=None): + def __init__(self, accessor, label=None): """ Instantiate a new ObjectAttribute. Parameters: accessor: The dotted path to the attribute being rendered (e.g. "site.region.name") label: Human-friendly label for the rendered attribute - template_name: The name of the template to render """ self.accessor = accessor - if template_name is not None: - self.template_name = template_name if label is not None: self.label = label @@ -53,25 +69,42 @@ class ObjectAttribute: Parameters: obj: The object for which the attribute is being rendered - context: The template context + context: The root template context """ return {} def render(self, obj, context): value = self.get_value(obj) + + # If the value is empty, render a placeholder if value in (None, ''): return self.placeholder - context = self.get_context(obj, context) + return render_to_string(self.template_name, { - **context, + **self.get_context(obj, context), + 'name': context['name'], 'value': value, }) class TextAttr(ObjectAttribute): + """ + A text attribute. + """ template_name = 'ui/attrs/text.html' def __init__(self, *args, style=None, format_string=None, copy_button=False, **kwargs): + """ + Instantiate a new TextAttr. + + Parameters: + accessor: The dotted path to the attribute being rendered (e.g. "site.region.name") + label: Human-friendly label for the rendered attribute + template_name: The name of the template to render + style: CSS class to apply to the rendered attribute + format_string: If specified, the value will be formatted using this string when rendering + copy_button: Set to True to include a copy-to-clipboard button + """ super().__init__(*args, **kwargs) self.style = style self.format_string = format_string @@ -81,7 +114,7 @@ class TextAttr(ObjectAttribute): value = resolve_attr_path(obj, self.accessor) # Apply format string (if any) if value and self.format_string: - value = self.format_string.format(value) + return self.format_string.format(value) return value def get_context(self, obj, context): @@ -92,9 +125,22 @@ class TextAttr(ObjectAttribute): class NumericAttr(ObjectAttribute): + """ + An integer or float attribute. + """ template_name = 'ui/attrs/numeric.html' def __init__(self, *args, unit_accessor=None, copy_button=False, **kwargs): + """ + Instantiate a new NumericAttr. + + Parameters: + accessor: The dotted path to the attribute being rendered (e.g. "site.region.name") + unit_accessor: Accessor for the unit of measurement to display alongside the value (if any) + copy_button: Set to True to include a copy-to-clipboard button + label: Human-friendly label for the rendered attribute + template_name: The name of the template to render + """ super().__init__(*args, **kwargs) self.unit_accessor = unit_accessor self.copy_button = copy_button @@ -108,6 +154,12 @@ class NumericAttr(ObjectAttribute): class ChoiceAttr(ObjectAttribute): + """ + A selection from a set of choices. + + The class calls get_FOO_display() on the object to retrieve the human-friendly choice label. If a get_FOO_color() + method exists on the object, it will be used to render a background color for the attribute value. + """ template_name = 'ui/attrs/choice.html' def get_value(self, obj): @@ -127,9 +179,21 @@ class ChoiceAttr(ObjectAttribute): class BooleanAttr(ObjectAttribute): + """ + A boolean attribute. + """ template_name = 'ui/attrs/boolean.html' def __init__(self, *args, display_false=True, **kwargs): + """ + Instantiate a new BooleanAttr. + + Parameters: + accessor: The dotted path to the attribute being rendered (e.g. "site.region.name") + display_false: If False, a placeholder will be rendered instead of the "False" indication + label: Human-friendly label for the rendered attribute + template_name: The name of the template to render + """ super().__init__(*args, **kwargs) self.display_false = display_false @@ -141,18 +205,38 @@ class BooleanAttr(ObjectAttribute): class ColorAttr(ObjectAttribute): + """ + An RGB color value. + """ template_name = 'ui/attrs/color.html' label = _('Color') class ImageAttr(ObjectAttribute): + """ + An attribute representing an image field on the model. Displays the uploaded image. + """ template_name = 'ui/attrs/image.html' -class ObjectAttr(ObjectAttribute): +class RelatedObjectAttr(ObjectAttribute): + """ + An attribute representing a related object. + """ template_name = 'ui/attrs/object.html' def __init__(self, *args, linkify=None, grouped_by=None, **kwargs): + """ + Instantiate a new RelatedObjectAttr. + + Parameters: + accessor: The dotted path to the attribute being rendered (e.g. "site.region.name") + linkify: If True, the rendered value will be hyperlinked to the related object's detail view + grouped_by: A second-order object to annotate alongside the related object; for example, an attribute + representing the dcim.Site model might specify grouped_by="region" + label: Human-friendly label for the rendered attribute + template_name: The name of the template to render + """ super().__init__(*args, **kwargs) self.linkify = linkify self.grouped_by = grouped_by @@ -167,9 +251,23 @@ class ObjectAttr(ObjectAttribute): class NestedObjectAttr(ObjectAttribute): + """ + An attribute representing a related nested object. Similar to `RelatedObjectAttr`, but includes the ancestors of the + related object in the rendered output. + """ template_name = 'ui/attrs/nested_object.html' def __init__(self, *args, linkify=None, max_depth=None, **kwargs): + """ + Instantiate a new NestedObjectAttr. Shows a related object as well as its ancestors. + + Parameters: + accessor: The dotted path to the attribute being rendered (e.g. "site.region.name") + linkify: If True, the rendered value will be hyperlinked to the related object's detail view + max_depth: Maximum number of ancestors to display (default: all) + label: Human-friendly label for the rendered attribute + template_name: The name of the template to render + """ super().__init__(*args, **kwargs) self.linkify = linkify self.max_depth = max_depth @@ -186,9 +284,21 @@ class NestedObjectAttr(ObjectAttribute): class AddressAttr(ObjectAttribute): + """ + A physical or mailing address. + """ template_name = 'ui/attrs/address.html' def __init__(self, *args, map_url=True, **kwargs): + """ + Instantiate a new AddressAttr. + + Parameters: + accessor: The dotted path to the attribute being rendered (e.g. "site.region.name") + map_url: If true, the address will render as a hyperlink using settings.MAPS_URL + label: Human-friendly label for the rendered attribute + template_name: The name of the template to render + """ super().__init__(*args, **kwargs) if map_url is True: self.map_url = get_config().MAPS_URL @@ -204,10 +314,23 @@ class AddressAttr(ObjectAttribute): class GPSCoordinatesAttr(ObjectAttribute): + """ + A GPS coordinates pair comprising latitude and longitude values. + """ template_name = 'ui/attrs/gps_coordinates.html' label = _('GPS coordinates') def __init__(self, latitude_attr='latitude', longitude_attr='longitude', map_url=True, **kwargs): + """ + Instantiate a new GPSCoordinatesAttr. + + Parameters: + latitude_attr: The name of the field containing the latitude value + longitude_attr: The name of the field containing the longitude value + map_url: If true, the address will render as a hyperlink using settings.MAPS_URL + label: Human-friendly label for the rendered attribute + template_name: The name of the template to render + """ super().__init__(accessor=None, **kwargs) self.latitude_attr = latitude_attr self.longitude_attr = longitude_attr @@ -233,13 +356,29 @@ class GPSCoordinatesAttr(ObjectAttribute): class TimezoneAttr(ObjectAttribute): + """ + A timezone value. Includes the numeric offset from UTC. + """ template_name = 'ui/attrs/timezone.html' class TemplatedAttr(ObjectAttribute): + """ + Renders an attribute using a custom template. + """ + def __init__(self, *args, template_name, context=None, **kwargs): + """ + Instantiate a new TemplatedAttr. - def __init__(self, *args, context=None, **kwargs): + Parameters: + accessor: The dotted path to the attribute being rendered (e.g. "site.region.name") + template_name: The name of the template to render + context: Additional context to pass to the template when rendering + label: Human-friendly label for the rendered attribute + template_name: The name of the template to render + """ super().__init__(*args, **kwargs) + self.template_name = template_name self.context = context or {} def get_context(self, obj, context): @@ -250,4 +389,7 @@ class TemplatedAttr(ObjectAttribute): class UtilizationAttr(ObjectAttribute): + """ + Renders the value of an attribute as a utilization graph. + """ template_name = 'ui/attrs/utilization.html' diff --git a/netbox/netbox/ui/layout.py b/netbox/netbox/ui/layout.py index 6612917a7..d3fc69535 100644 --- a/netbox/netbox/ui/layout.py +++ b/netbox/netbox/ui/layout.py @@ -13,7 +13,9 @@ __all__ = ( # class Layout: - + """ + A collection of rows and columns comprising the layout of content within the user interface. + """ def __init__(self, *rows): for i, row in enumerate(rows): if type(row) is not Row: @@ -22,7 +24,9 @@ class Layout: class Row: - + """ + A collection of columns arranged horizontally. + """ def __init__(self, *columns): for i, column in enumerate(columns): if type(column) is not Column: @@ -31,7 +35,9 @@ class Row: class Column: - + """ + A collection of panels arranged vertically. + """ def __init__(self, *panels): for i, panel in enumerate(panels): if not isinstance(panel, Panel): @@ -40,12 +46,18 @@ class Column: # -# Standard layouts +# Common layouts # class SimpleLayout(Layout): """ A layout with one row of two columns and a second row with one column. Includes registered plugin content. + + +------+------+ + | col1 | col2 | + +------+------+ + | col3 | + +-------------+ """ def __init__(self, left_panels=None, right_panels=None, bottom_panels=None): left_panels = left_panels or [] diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index 5827bb6b0..149b48563 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -1,5 +1,3 @@ -from abc import ABC, ABCMeta - from django.apps import apps from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ @@ -20,9 +18,9 @@ __all__ = ( 'ObjectPanel', 'ObjectsTablePanel', 'OrganizationalObjectPanel', - 'RelatedObjectsPanel', 'Panel', 'PluginContentPanel', + 'RelatedObjectsPanel', 'TemplatePanel', ) @@ -31,14 +29,13 @@ __all__ = ( # Base classes # -class Panel(ABC): +class Panel: """ A block of content rendered within an HTML template. - Attributes: - template_name: The name of the template to render - title: The human-friendly title of the panel - actions: A list of PanelActions to include in the panel header + Panels are arranged within rows and columns, (generally) render as discrete "cards" within the user interface. Each + panel has a title and may have one or more actions associated with it, which will be rendered as hyperlinks in the + top right corner of the card. """ template_name = None title = None @@ -50,7 +47,7 @@ class Panel(ABC): Parameters: title: The human-friendly title of the panel - actions: A list of PanelActions to include in the panel header + actions: An iterable of PanelActions to include in the panel header """ if title is not None: self.title = title @@ -95,7 +92,7 @@ class ObjectPanel(Panel): Instantiate a new ObjectPanel. Parameters: - accessor: The name of the attribute on the object (default: "object") + accessor: The dotted path in context data to the object being rendered (default: "object") """ super().__init__(**kwargs) @@ -103,12 +100,6 @@ class ObjectPanel(Panel): self.accessor = accessor def get_context(self, context): - """ - Return the context data to be used when rendering the panel. - - Parameters: - context: The template context - """ obj = resolve_attr_path(context, self.accessor) return { **super().get_context(context), @@ -117,7 +108,7 @@ class ObjectPanel(Panel): } -class ObjectAttributesPanelMeta(ABCMeta): +class ObjectAttributesPanelMeta(type): def __new__(mcls, name, bases, namespace, **kwargs): declared = {} @@ -148,9 +139,8 @@ class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta): """ A panel which displays selected attributes of an object. - Attributes: - template_name: The name of the template to render - accessor: The name of the attribute on the object + Attributes are added to the panel by declaring ObjectAttribute instances in the class body (similar to fields on + a Django form). Attributes are displayed in the order they are declared. """ template_name = 'ui/panels/object_attributes.html' @@ -159,7 +149,6 @@ class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta): Instantiate a new ObjectPanel. Parameters: - accessor: The name of the attribute on the object only: If specified, only attributes in this list will be displayed exclude: If specified, attributes in this list will be excluded from display """ @@ -181,12 +170,6 @@ class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta): return label def get_context(self, context): - """ - Return the context data to be used when rendering the panel. - - Parameters: - context: The template context - """ # Determine which attributes to display in the panel based on only/exclude args attr_names = set(self._attrs.keys()) if self.only: @@ -209,7 +192,7 @@ class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta): class OrganizationalObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttributesPanelMeta): """ - An ObjectPanel with attributes common to OrganizationalModels. + An ObjectPanel with attributes common to OrganizationalModels. Includes name and description. """ name = attrs.TextAttr('name', label=_('Name')) description = attrs.TextAttr('description', label=_('Description')) @@ -217,7 +200,7 @@ class OrganizationalObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttribute class NestedGroupObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttributesPanelMeta): """ - An ObjectPanel with attributes common to NestedGroupObjects. + An ObjectPanel with attributes common to NestedGroupObjects. Includes the parent object. """ parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True) @@ -234,18 +217,12 @@ class CommentsPanel(ObjectPanel): Instantiate a new CommentsPanel. Parameters: - field_name: The name of the comment field on the object + field_name: The name of the comment field on the object (default: "comments") """ super().__init__(**kwargs) self.field_name = field_name def get_context(self, context): - """ - Return the context data to be used when rendering the panel. - - Parameters: - context: The template context - """ return { **super().get_context(context), 'comments': getattr(context['object'], self.field_name), @@ -270,17 +247,9 @@ class JSONPanel(ObjectPanel): self.field_name = field_name if copy_button: - self.actions.append( - CopyContent(f'panel_{field_name}'), - ) + self.actions.append(CopyContent(f'panel_{field_name}')) def get_context(self, context): - """ - Return the context data to be used when rendering the panel. - - Parameters: - context: The template context - """ return { **super().get_context(context), 'data': getattr(context['object'], self.field_name), @@ -300,12 +269,6 @@ class RelatedObjectsPanel(Panel): title = _('Related Objects') def get_context(self, context): - """ - Return the context data to be used when rendering the panel. - - Parameters: - context: The template context - """ return { **super().get_context(context), 'related_models': context.get('related_models'), @@ -343,12 +306,6 @@ class ObjectsTablePanel(Panel): self.title = title(self.model._meta.verbose_name_plural) def get_context(self, context): - """ - Return the context data to be used when rendering the panel. - - Parameters: - context: The template context - """ url_params = { k: v(context) if callable(v) else v for k, v in self.filters.items() } @@ -363,7 +320,7 @@ class ObjectsTablePanel(Panel): class TemplatePanel(Panel): """ - A panel which renders content using an HTML template. + A panel which renders custom content using an HTML template. """ def __init__(self, template_name, **kwargs): """ @@ -385,7 +342,7 @@ class PluginContentPanel(Panel): A panel which displays embedded plugin content. Parameters: - method: The name of the plugin method to render (e.g. left_page) + method: The name of the plugin method to render (e.g. "left_page") """ def __init__(self, method, **kwargs): super().__init__(**kwargs) diff --git a/netbox/templates/dcim/device/attrs/ipaddress.html b/netbox/templates/dcim/device/attrs/ipaddress.html index 2af5dab6c..7b4345657 100644 --- a/netbox/templates/dcim/device/attrs/ipaddress.html +++ b/netbox/templates/dcim/device/attrs/ipaddress.html @@ -1,4 +1,3 @@ -{# TODO: Add copy-to-clipboard button #} {% load i18n %} {{ value.address.ip }} {% if value.nat_inside %} diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py index cab4f9f20..663bf5647 100644 --- a/netbox/utilities/templatetags/builtins/tags.py +++ b/netbox/utilities/templatetags/builtins/tags.py @@ -184,4 +184,7 @@ def static_with_params(path, **params): @register.simple_tag(takes_context=True) def render(context, component): + """ + Render a UI component (e.g. a Panel) by calling its render() method and passing the current template context. + """ return mark_safe(component.render(context)) From 917280d1d3a392f29a1151faff4ebe2e9dd21d44 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 7 Nov 2025 15:37:20 -0500 Subject: [PATCH 076/125] Add plugin dev docs for UI components --- docs/plugins/development/ui-components.md | 148 ++++++++++++++++++++ mkdocs.yml | 1 + netbox/netbox/ui/actions.py | 98 +++++-------- netbox/netbox/ui/attrs.py | 135 ++++++------------ netbox/netbox/ui/layout.py | 31 +++- netbox/netbox/ui/panels.py | 89 +++++------- netbox/netbox/views/generic/object_views.py | 4 +- 7 files changed, 289 insertions(+), 217 deletions(-) create mode 100644 docs/plugins/development/ui-components.md diff --git a/docs/plugins/development/ui-components.md b/docs/plugins/development/ui-components.md new file mode 100644 index 000000000..a8fe2eff0 --- /dev/null +++ b/docs/plugins/development/ui-components.md @@ -0,0 +1,148 @@ +# UI Components + +!!! note "New in NetBox v4.5" + All UI components described here were introduced in NetBox v4.5. Be sure to set the minimum NetBox version to 4.5.0 for your plugin before incorporating any of these resources. + +!!! danger "Beta Feature" + UI components are considered a beta feature, and are still under active development. Please be aware that the API for resources on this page is subject to change in future releases. + +To simply the process of designing your plugin's user interface, and to encourage a consistent look and feel throughout the entire application, NetBox provides a set of components that enable programmatic UI design. These make it possible to declare complex page layouts with little or no custom HTML. + +## Page Layout + +A layout defines the general arrangement of content on a page into rows and columns. The layout is defined under the [view](./views.md) and declares a set of rows, each of which may have one or more columns. Below is an example layout. + +``` ++-------+-------+-------+ +| Col 1 | Col 2 | Col 3 | ++-------+-------+-------+ +| Col 4 | ++-----------+-----------+ +| Col 5 | Col 6 | ++-----------+-----------+ +``` + +The above layout can be achieved with the following declaration under a view: + +```python +from netbox.ui import layout +from netbox.views import generic + +class MyView(generic.ObjectView): + layout = layout.Layout( + layout.Row( + layout.Column(), + layout.Column(), + layout.Column(), + ), + layout.Row( + layout.Column(), + ), + layout.Row( + layout.Column(), + layout.Column(), + ), + ) +``` + +!!! note + Currently, layouts are supported only for subclasses of [`generic.ObjectView`](./views.md#netbox.views.generic.ObjectView). + +::: netbox.ui.layout.Layout + +::: netbox.ui.layout.SimpleLayout + +::: netbox.ui.layout.Row + +::: netbox.ui.layout.Column + +## Panels + +Within each column, related blocks of content are arranged into panels. Each panel has a title and may have a set of associated actions, but the content within is otherwise arbitrary. + +Plugins can define their own panels by inheriting from the base class `netbox.ui.panels.Panel`. Override the `get_context()` method to pass additional context to your custom panel template. An example is provided below. + +```python +from django.utils.translation import gettext_lazy as _ +from netbox.ui.panels import Panel + +class RecentChangesPanel(Panel): + template_name = 'my_plugin/panels/recent_changes.html' + title = _('Recent Changes') + + def get_context(self, context): + return { + **super().get_context(context), + 'changes': get_changes()[:10], + } +``` + +NetBox also includes a set of panels suite for specific uses, such as display object details or embedding a table of related objects. These are listed below. + +::: netbox.ui.panels.Panel + +::: netbox.ui.panels.ObjectPanel + +::: netbox.ui.panels.ObjectAttributesPanel + +#### Object Attributes + +The following classes are available to represent object attributes within an ObjectAttributesPanel. Additionally, plugins can subclass `netbox.ui.attrs.ObjectAttribute` to create custom classes. + +| Class | Description | +|--------------------------------------|--------------------------------------------------| +| `netbox.ui.attrs.AddressAttr` | A physical or mailing address. | +| `netbox.ui.attrs.BooleanAttr` | A boolean value | +| `netbox.ui.attrs.ColorAttr` | A color expressed in RGB | +| `netbox.ui.attrs.ChoiceAttr` | A selection from a set of choices | +| `netbox.ui.attrs.GPSCoordinatesAttr` | GPS coordinates (latitude and longitude) | +| `netbox.ui.attrs.ImageAttr` | An attached image (displays the image) | +| `netbox.ui.attrs.NestedObjectAttr` | A related nested object | +| `netbox.ui.attrs.NumericAttr` | An integer or float value | +| `netbox.ui.attrs.RelatedObjectAttr` | A related object | +| `netbox.ui.attrs.TemplatedAttr` | Renders an attribute using a custom template | +| `netbox.ui.attrs.TextAttr` | A string (text) value | +| `netbox.ui.attrs.TimezoneAttr` | A timezone with annotated offset | +| `netbox.ui.attrs.UtilizationAttr` | A numeric value expressed as a utilization graph | + +::: netbox.ui.panels.OrganizationalObjectPanel + +::: netbox.ui.panels.NestedGroupObjectPanel + +::: netbox.ui.panels.CommentsPanel + +::: netbox.ui.panels.JSONPanel + +::: netbox.ui.panels.RelatedObjectsPanel + +::: netbox.ui.panels.ObjectsTablePanel + +::: netbox.ui.panels.TemplatePanel + +::: netbox.ui.panels.PluginContentPanel + +## Panel Actions + +Each panel may have actions associated with it. These render as links or buttons within the panel header, opposite the panel's title. For example, a common use case is to include an "Add" action on a panel which displays a list of objects. Below is an example of this. + +```python +from django.utils.translation import gettext_lazy as _ +from netbox.ui import actions, panels + +panels.ObjectsTablePanel( + model='dcim.Region', + title=_('Child Regions'), + filters={'parent_id': lambda ctx: ctx['object'].pk}, + actions=[ + actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}), + ], +), +``` + +::: netbox.ui.actions.PanelAction + +::: netbox.ui.actions.LinkAction + +::: netbox.ui.actions.AddObject + +::: netbox.ui.actions.CopyContent diff --git a/mkdocs.yml b/mkdocs.yml index 078fc5e50..07628e775 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -143,6 +143,7 @@ nav: - Getting Started: 'plugins/development/index.md' - Models: 'plugins/development/models.md' - Views: 'plugins/development/views.md' + - UI Components: 'plugins/development/ui-components.md' - Navigation: 'plugins/development/navigation.md' - Templates: 'plugins/development/templates.md' - Tables: 'plugins/development/tables.md' diff --git a/netbox/netbox/ui/actions.py b/netbox/netbox/ui/actions.py index 2dd8482b2..7579e7b93 100644 --- a/netbox/netbox/ui/actions.py +++ b/netbox/netbox/ui/actions.py @@ -11,6 +11,7 @@ from utilities.views import get_viewname __all__ = ( 'AddObject', 'CopyContent', + 'LinkAction', 'PanelAction', ) @@ -20,34 +21,28 @@ class PanelAction: A link (typically a button) within a panel to perform some associated action, such as adding an object. Attributes: - template_name: The name of the template to render - label: The default human-friendly button text - button_class: Bootstrap CSS class for the button - button_icon: Name of the button's MDI icon + template_name (str): The name of the template to render + + Parameters: + label (str): The human-friendly button text + permissions (list): An iterable of permissions required to display the action + button_class (str): Bootstrap CSS class for the button + button_icon (str): Name of the button's MDI icon """ template_name = None - label = None - button_class = 'primary' - button_icon = None - def __init__(self, label=None, permissions=None): - """ - Initialize a new PanelAction. - - Parameters: - label: The human-friendly button text - permissions: A list of permissions required to display the action - """ - if label is not None: - self.label = label + def __init__(self, label, permissions=None, button_class='primary', button_icon=None): + self.label = label self.permissions = permissions + self.button_class = button_class + self.button_icon = button_icon def get_context(self, context): """ Return the template context used to render the action element. Parameters: - context: The template context + context (dict): The template context """ return { 'label': self.label, @@ -60,7 +55,7 @@ class PanelAction: Render the action as HTML. Parameters: - context: The template context + context (dict): The template context """ # Enforce permissions user = context['request'].user @@ -74,26 +69,16 @@ class LinkAction(PanelAction): """ A hyperlink (typically a button) within a panel to perform some associated action, such as adding an object. - Attributes: - label: The default human-friendly button text - button_class: Bootstrap CSS class for the button - button_icon: Name of the button's MDI icon + Parameters: + view_name (str): Name of the view to which the action will link + view_kwargs (dict): Additional keyword arguments to pass to `reverse()` when resolving the URL + url_params (dict): A dictionary of arbitrary URL parameters to append to the action's URL. If the value of a key + is a callable, it will be passed the current template context. """ template_name = 'ui/actions/link.html' def __init__(self, view_name, view_kwargs=None, url_params=None, **kwargs): - """ - Initialize a new PanelAction. - - Parameters: - view_name: Name of the view to which the action will link - view_kwargs: Additional keyword arguments to pass to the view when resolving its URL - url_params: A dictionary of arbitrary URL parameters to append to the action's URL - permissions: A list of permissions required to display the action - label: The human-friendly button text - """ super().__init__(**kwargs) - self.view_name = view_name self.view_kwargs = view_kwargs or {} self.url_params = url_params or {} @@ -103,7 +88,7 @@ class LinkAction(PanelAction): Resolve the URL for the action from its view name and kwargs. Append any additional URL parameters. Parameters: - context: The template context + context (dict): The template context """ url = reverse(self.view_name, kwargs=self.view_kwargs) if self.url_params: @@ -127,19 +112,12 @@ class LinkAction(PanelAction): class AddObject(LinkAction): """ An action to add a new object. + + Parameters: + model (str): The dotted label of the model to be added (e.g. "dcim.site") + url_params (dict): A dictionary of arbitrary URL parameters to append to the resolved URL """ - label = _('Add') - button_icon = 'plus-thick' - - def __init__(self, model, url_params=None, label=None): - """ - Initialize a new AddObject action. - - Parameters: - model: The dotted label of the model to be added (e.g. "dcim.site") - url_params: A dictionary of arbitrary URL parameters to append to the resolved URL - label: The human-friendly button text - """ + def __init__(self, model, url_params=None, **kwargs): # Resolve the model class from its app.name label try: app_label, model_name = model.split('.') @@ -148,37 +126,29 @@ class AddObject(LinkAction): raise ValueError(f"Invalid model label: {model}") view_name = get_viewname(model, 'add') - super().__init__(view_name=view_name, url_params=url_params, label=label) + kwargs.setdefault('label', _('Add')) + kwargs.setdefault('button_icon', 'plus-thick') + kwargs.setdefault('permissions', [get_permission_for_model(model, 'add')]) - # Require "add" permission on the model - self.permissions = [get_permission_for_model(model, 'add')] + super().__init__(view_name=view_name, url_params=url_params, **kwargs) class CopyContent(PanelAction): """ An action to copy the contents of a panel to the clipboard. + + Parameters: + target_id (str): The ID of the target element containing the content to be copied """ template_name = 'ui/actions/copy_content.html' - label = _('Copy') - button_icon = 'content-copy' def __init__(self, target_id, **kwargs): - """ - Instantiate a new CopyContent action. - - Parameters: - target_id: The ID of the target element containing the content to be copied - """ + kwargs.setdefault('label', _('Copy')) + kwargs.setdefault('button_icon', 'content-copy') super().__init__(**kwargs) self.target_id = target_id def render(self, context): - """ - Render the action as HTML. - - Parameters: - context: The template context - """ return render_to_string(self.template_name, { 'target_id': self.target_id, 'label': self.label, diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index 53e295246..bf55e3f3c 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -34,22 +34,18 @@ class ObjectAttribute: Base class for representing an attribute of an object. Attributes: - template_name: The name of the template to render - label: Human-friendly label for the rendered attribute - placeholder: HTML to render for empty/null values + template_name (str): The name of the template to render + placeholder (str): HTML to render for empty/null values + + Parameters: + accessor (str): The dotted path to the attribute being rendered (e.g. "site.region.name") + label (str): Human-friendly label for the rendered attribute """ template_name = None label = None placeholder = mark_safe(PLACEHOLDER_HTML) def __init__(self, accessor, label=None): - """ - Instantiate a new ObjectAttribute. - - Parameters: - accessor: The dotted path to the attribute being rendered (e.g. "site.region.name") - label: Human-friendly label for the rendered attribute - """ self.accessor = accessor if label is not None: self.label = label @@ -59,7 +55,7 @@ class ObjectAttribute: Return the value of the attribute. Parameters: - obj: The object for which the attribute is being rendered + obj (object): The object for which the attribute is being rendered """ return resolve_attr_path(obj, self.accessor) @@ -68,8 +64,8 @@ class ObjectAttribute: Return any additional template context used to render the attribute value. Parameters: - obj: The object for which the attribute is being rendered - context: The root template context + obj (object): The object for which the attribute is being rendered + context (dict): The root template context """ return {} @@ -90,21 +86,15 @@ class ObjectAttribute: class TextAttr(ObjectAttribute): """ A text attribute. + + Parameters: + style (str): CSS class to apply to the rendered attribute + format_string (str): If specified, the value will be formatted using this string when rendering + copy_button (bool): Set to True to include a copy-to-clipboard button """ template_name = 'ui/attrs/text.html' def __init__(self, *args, style=None, format_string=None, copy_button=False, **kwargs): - """ - Instantiate a new TextAttr. - - Parameters: - accessor: The dotted path to the attribute being rendered (e.g. "site.region.name") - label: Human-friendly label for the rendered attribute - template_name: The name of the template to render - style: CSS class to apply to the rendered attribute - format_string: If specified, the value will be formatted using this string when rendering - copy_button: Set to True to include a copy-to-clipboard button - """ super().__init__(*args, **kwargs) self.style = style self.format_string = format_string @@ -127,20 +117,14 @@ class TextAttr(ObjectAttribute): class NumericAttr(ObjectAttribute): """ An integer or float attribute. + + Parameters: + unit_accessor (str): Accessor for the unit of measurement to display alongside the value (if any) + copy_button (bool): Set to True to include a copy-to-clipboard button """ template_name = 'ui/attrs/numeric.html' def __init__(self, *args, unit_accessor=None, copy_button=False, **kwargs): - """ - Instantiate a new NumericAttr. - - Parameters: - accessor: The dotted path to the attribute being rendered (e.g. "site.region.name") - unit_accessor: Accessor for the unit of measurement to display alongside the value (if any) - copy_button: Set to True to include a copy-to-clipboard button - label: Human-friendly label for the rendered attribute - template_name: The name of the template to render - """ super().__init__(*args, **kwargs) self.unit_accessor = unit_accessor self.copy_button = copy_button @@ -181,19 +165,13 @@ class ChoiceAttr(ObjectAttribute): class BooleanAttr(ObjectAttribute): """ A boolean attribute. + + Parameters: + display_false (bool): If False, a placeholder will be rendered instead of the "False" indication """ template_name = 'ui/attrs/boolean.html' def __init__(self, *args, display_false=True, **kwargs): - """ - Instantiate a new BooleanAttr. - - Parameters: - accessor: The dotted path to the attribute being rendered (e.g. "site.region.name") - display_false: If False, a placeholder will be rendered instead of the "False" indication - label: Human-friendly label for the rendered attribute - template_name: The name of the template to render - """ super().__init__(*args, **kwargs) self.display_false = display_false @@ -222,21 +200,15 @@ class ImageAttr(ObjectAttribute): class RelatedObjectAttr(ObjectAttribute): """ An attribute representing a related object. + + Parameters: + linkify (bool): If True, the rendered value will be hyperlinked to the related object's detail view + grouped_by (str): A second-order object to annotate alongside the related object; for example, an attribute + representing the dcim.Site model might specify grouped_by="region" """ template_name = 'ui/attrs/object.html' def __init__(self, *args, linkify=None, grouped_by=None, **kwargs): - """ - Instantiate a new RelatedObjectAttr. - - Parameters: - accessor: The dotted path to the attribute being rendered (e.g. "site.region.name") - linkify: If True, the rendered value will be hyperlinked to the related object's detail view - grouped_by: A second-order object to annotate alongside the related object; for example, an attribute - representing the dcim.Site model might specify grouped_by="region" - label: Human-friendly label for the rendered attribute - template_name: The name of the template to render - """ super().__init__(*args, **kwargs) self.linkify = linkify self.grouped_by = grouped_by @@ -254,20 +226,14 @@ class NestedObjectAttr(ObjectAttribute): """ An attribute representing a related nested object. Similar to `RelatedObjectAttr`, but includes the ancestors of the related object in the rendered output. + + Parameters: + linkify (bool): If True, the rendered value will be hyperlinked to the related object's detail view + max_depth (int): Maximum number of ancestors to display (default: all) """ template_name = 'ui/attrs/nested_object.html' def __init__(self, *args, linkify=None, max_depth=None, **kwargs): - """ - Instantiate a new NestedObjectAttr. Shows a related object as well as its ancestors. - - Parameters: - accessor: The dotted path to the attribute being rendered (e.g. "site.region.name") - linkify: If True, the rendered value will be hyperlinked to the related object's detail view - max_depth: Maximum number of ancestors to display (default: all) - label: Human-friendly label for the rendered attribute - template_name: The name of the template to render - """ super().__init__(*args, **kwargs) self.linkify = linkify self.max_depth = max_depth @@ -286,19 +252,13 @@ class NestedObjectAttr(ObjectAttribute): class AddressAttr(ObjectAttribute): """ A physical or mailing address. + + Parameters: + map_url (bool): If true, the address will render as a hyperlink using settings.MAPS_URL """ template_name = 'ui/attrs/address.html' def __init__(self, *args, map_url=True, **kwargs): - """ - Instantiate a new AddressAttr. - - Parameters: - accessor: The dotted path to the attribute being rendered (e.g. "site.region.name") - map_url: If true, the address will render as a hyperlink using settings.MAPS_URL - label: Human-friendly label for the rendered attribute - template_name: The name of the template to render - """ super().__init__(*args, **kwargs) if map_url is True: self.map_url = get_config().MAPS_URL @@ -316,21 +276,16 @@ class AddressAttr(ObjectAttribute): class GPSCoordinatesAttr(ObjectAttribute): """ A GPS coordinates pair comprising latitude and longitude values. + + Parameters: + latitude_attr (float): The name of the field containing the latitude value + longitude_attr (float): The name of the field containing the longitude value + map_url (bool): If true, the address will render as a hyperlink using settings.MAPS_URL """ template_name = 'ui/attrs/gps_coordinates.html' label = _('GPS coordinates') def __init__(self, latitude_attr='latitude', longitude_attr='longitude', map_url=True, **kwargs): - """ - Instantiate a new GPSCoordinatesAttr. - - Parameters: - latitude_attr: The name of the field containing the latitude value - longitude_attr: The name of the field containing the longitude value - map_url: If true, the address will render as a hyperlink using settings.MAPS_URL - label: Human-friendly label for the rendered attribute - template_name: The name of the template to render - """ super().__init__(accessor=None, **kwargs) self.latitude_attr = latitude_attr self.longitude_attr = longitude_attr @@ -365,18 +320,12 @@ class TimezoneAttr(ObjectAttribute): class TemplatedAttr(ObjectAttribute): """ Renders an attribute using a custom template. + + Parameters: + template_name (str): The name of the template to render + context (dict): Additional context to pass to the template when rendering """ def __init__(self, *args, template_name, context=None, **kwargs): - """ - Instantiate a new TemplatedAttr. - - Parameters: - accessor: The dotted path to the attribute being rendered (e.g. "site.region.name") - template_name: The name of the template to render - context: Additional context to pass to the template when rendering - label: Human-friendly label for the rendered attribute - template_name: The name of the template to render - """ super().__init__(*args, **kwargs) self.template_name = template_name self.context = context or {} diff --git a/netbox/netbox/ui/layout.py b/netbox/netbox/ui/layout.py index d3fc69535..b59fd7b34 100644 --- a/netbox/netbox/ui/layout.py +++ b/netbox/netbox/ui/layout.py @@ -15,6 +15,9 @@ __all__ = ( class Layout: """ A collection of rows and columns comprising the layout of content within the user interface. + + Parameters: + *rows: One or more Row instances """ def __init__(self, *rows): for i, row in enumerate(rows): @@ -26,6 +29,9 @@ class Layout: class Row: """ A collection of columns arranged horizontally. + + Parameters: + *columns: One or more Column instances """ def __init__(self, *columns): for i, column in enumerate(columns): @@ -37,6 +43,9 @@ class Row: class Column: """ A collection of panels arranged vertically. + + Parameters: + *panels: One or more Panel instances """ def __init__(self, *panels): for i, panel in enumerate(panels): @@ -51,13 +60,23 @@ class Column: class SimpleLayout(Layout): """ - A layout with one row of two columns and a second row with one column. Includes registered plugin content. + A layout with one row of two columns and a second row with one column. - +------+------+ - | col1 | col2 | - +------+------+ - | col3 | - +-------------+ + Plugin content registered for `left_page`, `right_page`, or `full_width_path` is included automatically. Most object + views in NetBox utilize this layout. + + ``` + +-------+-------+ + | Col 1 | Col 2 | + +-------+-------+ + | Col 3 | + +---------------+ + ``` + + Parameters: + left_panels: Panel instances to be rendered in the top lefthand column + right_panels: Panel instances to be rendered in the top righthand column + bottom_panels: Panel instances to be rendered in the bottom row """ def __init__(self, left_panels=None, right_panels=None, bottom_panels=None): left_panels = left_panels or [] diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index 149b48563..5699c64e9 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -36,19 +36,19 @@ class Panel: Panels are arranged within rows and columns, (generally) render as discrete "cards" within the user interface. Each panel has a title and may have one or more actions associated with it, which will be rendered as hyperlinks in the top right corner of the card. + + Attributes: + template_name (str): The name of the template used to render the panel + + Parameters: + title (str): The human-friendly title of the panel + actions (list): An iterable of PanelActions to include in the panel header """ template_name = None title = None actions = None def __init__(self, title=None, actions=None): - """ - Instantiate a new Panel. - - Parameters: - title: The human-friendly title of the panel - actions: An iterable of PanelActions to include in the panel header - """ if title is not None: self.title = title self.actions = actions or self.actions or [] @@ -58,7 +58,7 @@ class Panel: Return the context data to be used when rendering the panel. Parameters: - context: The template context + context (dict): The template context """ return { 'request': context.get('request'), @@ -72,7 +72,7 @@ class Panel: Render the panel as HTML. Parameters: - context: The template context + context (dict): The template context """ return render_to_string(self.template_name, self.get_context(context)) @@ -84,16 +84,13 @@ class Panel: class ObjectPanel(Panel): """ Base class for object-specific panels. + + Parameters: + accessor (str): The dotted path in context data to the object being rendered (default: "object") """ accessor = 'object' def __init__(self, accessor=None, **kwargs): - """ - Instantiate a new ObjectPanel. - - Parameters: - accessor: The dotted path in context data to the object being rendered (default: "object") - """ super().__init__(**kwargs) if accessor is not None: @@ -141,17 +138,16 @@ class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta): Attributes are added to the panel by declaring ObjectAttribute instances in the class body (similar to fields on a Django form). Attributes are displayed in the order they are declared. + + Note that the `only` and `exclude` parameters are mutually exclusive. + + Parameters: + only (list): If specified, only attributes in this list will be displayed + exclude (list): If specified, attributes in this list will be excluded from display """ template_name = 'ui/panels/object_attributes.html' def __init__(self, only=None, exclude=None, **kwargs): - """ - Instantiate a new ObjectPanel. - - Parameters: - only: If specified, only attributes in this list will be displayed - exclude: If specified, attributes in this list will be excluded from display - """ super().__init__(**kwargs) # Set included/excluded attributes @@ -192,7 +188,7 @@ class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta): class OrganizationalObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttributesPanelMeta): """ - An ObjectPanel with attributes common to OrganizationalModels. Includes name and description. + An ObjectPanel with attributes common to OrganizationalModels. Includes `name` and `description` attributes. """ name = attrs.TextAttr('name', label=_('Name')) description = attrs.TextAttr('description', label=_('Description')) @@ -200,25 +196,24 @@ class OrganizationalObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttribute class NestedGroupObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttributesPanelMeta): """ - An ObjectPanel with attributes common to NestedGroupObjects. Includes the parent object. + An ObjectPanel with attributes common to NestedGroupObjects. Includes the `parent` attribute. """ + name = attrs.TextAttr('name', label=_('Name')) parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True) + description = attrs.TextAttr('description', label=_('Description')) class CommentsPanel(ObjectPanel): """ A panel which displays comments associated with an object. + + Parameters: + field_name (str): The name of the comment field on the object (default: "comments") """ template_name = 'ui/panels/comments.html' title = _('Comments') def __init__(self, field_name='comments', **kwargs): - """ - Instantiate a new CommentsPanel. - - Parameters: - field_name: The name of the comment field on the object (default: "comments") - """ super().__init__(**kwargs) self.field_name = field_name @@ -232,17 +227,14 @@ class CommentsPanel(ObjectPanel): class JSONPanel(ObjectPanel): """ A panel which renders formatted JSON data from an object's JSONField. + + Parameters: + field_name (str): The name of the JSON field on the object + copy_button (bool): Set to True (default) to include a copy-to-clipboard button """ template_name = 'ui/panels/json.html' def __init__(self, field_name, copy_button=True, **kwargs): - """ - Instantiate a new JSONPanel. - - Parameters: - field_name: The name of the JSON field on the object - copy_button: Set to True (default) to include a copy-to-clipboard button - """ super().__init__(**kwargs) self.field_name = field_name @@ -278,18 +270,16 @@ class RelatedObjectsPanel(Panel): class ObjectsTablePanel(Panel): """ A panel which displays a table of objects (rendered via HTMX). + + Parameters: + model (str): The dotted label of the model to be added (e.g. "dcim.site") + filters (dict): A dictionary of arbitrary URL parameters to append to the table's URL. If the value of a key is + a callable, it will be passed the current template context. """ template_name = 'ui/panels/objects_table.html' title = None def __init__(self, model, filters=None, **kwargs): - """ - Instantiate a new ObjectsTablePanel. - - Parameters: - model: The dotted label of the model to be added (e.g. "dcim.site") - filters: A dictionary of arbitrary URL parameters to append to the table's URL - """ super().__init__(**kwargs) # Resolve the model class from its app.name label @@ -321,14 +311,11 @@ class ObjectsTablePanel(Panel): class TemplatePanel(Panel): """ A panel which renders custom content using an HTML template. + + Parameters: + template_name (str): The name of the template to render """ def __init__(self, template_name, **kwargs): - """ - Instantiate a new TemplatePanel. - - Parameters: - template_name: The name of the template to render - """ super().__init__(**kwargs) self.template_name = template_name @@ -342,7 +329,7 @@ class PluginContentPanel(Panel): A panel which displays embedded plugin content. Parameters: - method: The name of the plugin method to render (e.g. "left_page") + method (str): The name of the plugin method to render (e.g. "left_page") """ def __init__(self, method, **kwargs): super().__init__(**kwargs) diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index eb1a4d3a9..88a3456f7 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -44,6 +44,7 @@ class ObjectView(ActionsMixin, BaseObjectView): Note: If `template_name` is not specified, it will be determined automatically based on the queryset model. Attributes: + layout: An instance of `netbox.ui.layout.Layout` which defines the page layout (overrides HTML template) tab: A ViewTab instance for the view actions: An iterable of ObjectAction subclasses (see ActionsMixin) """ @@ -59,9 +60,6 @@ class ObjectView(ActionsMixin, BaseObjectView): Return self.template_name if defined. Otherwise, dynamically resolve the template name using the queryset model's `app_label` and `model_name`. """ - # TODO: Temporarily allow layout to override template_name - if self.layout is not None: - return 'generic/object.html' if self.template_name is not None: return self.template_name model_opts = self.queryset.model._meta From 7b0e8c1a0d30fff66ee9519eaff20201875bb646 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 7 Nov 2025 16:24:45 -0500 Subject: [PATCH 077/125] Remove obsolete template HTML --- netbox/dcim/ui/panels.py | 14 +- netbox/dcim/views.py | 8 +- netbox/netbox/ui/panels.py | 2 +- netbox/templates/dcim/device.html | 374 ------------------ netbox/templates/dcim/devicetype.html | 111 ------ netbox/templates/dcim/location.html | 97 ----- netbox/templates/dcim/manufacturer.html | 35 -- netbox/templates/dcim/moduletypeprofile.html | 59 --- .../panels/rack_reservation_elevations.html | 15 + netbox/templates/dcim/rack.html | 152 ------- .../dcim/rack/attrs/total_weight.html | 3 + netbox/templates/dcim/rackreservation.html | 91 ----- netbox/templates/dcim/rackrole.html | 41 -- netbox/templates/dcim/racktype.html | 75 ---- netbox/templates/dcim/region.html | 53 --- netbox/templates/dcim/site.html | 138 ------- netbox/templates/dcim/sitegroup.html | 53 --- 17 files changed, 36 insertions(+), 1285 deletions(-) delete mode 100644 netbox/templates/dcim/moduletypeprofile.html create mode 100644 netbox/templates/dcim/panels/rack_reservation_elevations.html create mode 100644 netbox/templates/dcim/rack/attrs/total_weight.html delete mode 100644 netbox/templates/dcim/racktype.html diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index 4661e3151..87ceb9c4a 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -6,6 +6,7 @@ from netbox.ui import attrs, panels class SitePanel(panels.ObjectAttributesPanel): region = attrs.NestedObjectAttr('region', linkify=True) group = attrs.NestedObjectAttr('group', linkify=True) + name = attrs.TextAttr('name') status = attrs.ChoiceAttr('status') tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group') facility = attrs.TextAttr('facility') @@ -42,7 +43,8 @@ class RackPanel(panels.ObjectAttributesPanel): region = attrs.NestedObjectAttr('site.region', linkify=True) site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group') location = attrs.NestedObjectAttr('location', linkify=True) - facility = attrs.TextAttr('facility') + name = attrs.TextAttr('name') + facility = attrs.TextAttr('facility', label=_('Facility ID')) tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group') status = attrs.ChoiceAttr('status') rack_type = attrs.RelatedObjectAttr('rack_type', linkify=True, grouped_by='manufacturer') @@ -58,13 +60,21 @@ class RackPanel(panels.ObjectAttributesPanel): class RackWeightPanel(panels.ObjectAttributesPanel): weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display') max_weight = attrs.NumericAttr('max_weight', unit_accessor='get_weight_unit_display', label=_('Maximum weight')) - total_weight = attrs.NumericAttr('total_weight', unit_accessor='get_weight_unit_display') + total_weight = attrs.TemplatedAttr('total_weight', template_name='dcim/rack/attrs/total_weight.html') class RackRolePanel(panels.OrganizationalObjectPanel): color = attrs.ColorAttr('color') +class RackReservationPanel(panels.ObjectAttributesPanel): + units = attrs.TextAttr('unit_list') + status = attrs.ChoiceAttr('status') + tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group') + user = attrs.RelatedObjectAttr('user') + description = attrs.TextAttr('description') + + class RackTypePanel(panels.ObjectAttributesPanel): manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True) model = attrs.TextAttr('model') diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 4cb5233e1..508b7e6f2 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -866,6 +866,7 @@ class RackTypeListView(generic.ObjectListView): @register_model_view(RackType) class RackTypeView(GetRelatedModelsMixin, generic.ObjectView): + template_name = 'generic/object.html' queryset = RackType.objects.all() layout = layout.SimpleLayout( left_panels=[ @@ -1147,14 +1148,14 @@ class RackReservationView(generic.ObjectView): queryset = RackReservation.objects.all() layout = layout.SimpleLayout( left_panels=[ - panels.RackPanel(accessor='object.rack', only=['region', 'site', 'location']), + panels.RackPanel(accessor='object.rack', only=['region', 'site', 'location', 'name']), + panels.RackReservationPanel(title=_('Reservation')), CustomFieldsPanel(), TagsPanel(), CommentsPanel(), - ImageAttachmentsPanel(), ], right_panels=[ - TemplatePanel('dcim/panels/rack_elevations.html'), + TemplatePanel(template_name='dcim/panels/rack_reservation_elevations.html'), RelatedObjectsPanel(), ], ) @@ -1570,6 +1571,7 @@ class ModuleTypeProfileListView(generic.ObjectListView): @register_model_view(ModuleTypeProfile) class ModuleTypeProfileView(GetRelatedModelsMixin, generic.ObjectView): + template_name = 'generic/object.html' queryset = ModuleTypeProfile.objects.all() layout = layout.SimpleLayout( left_panels=[ diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index 5699c64e9..e62d9f422 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -198,8 +198,8 @@ class NestedGroupObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttributesPa """ An ObjectPanel with attributes common to NestedGroupObjects. Includes the `parent` attribute. """ - name = attrs.TextAttr('name', label=_('Name')) parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True) + name = attrs.TextAttr('name', label=_('Name')) description = attrs.TextAttr('description', label=_('Description')) diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index f8b8e95c2..9719d6df1 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -1,375 +1 @@ {% extends 'dcim/device/base.html' %} -{% load render_table from django_tables2 %} -{% load buttons %} -{% load static %} -{% load helpers %} -{% load plugins %} -{% load i18n %} -{% load l10n %} -{% load mptt %} - -{% block content %} -
-
-
-

{% trans "Device" %}

- - - - - - - - - - - - - - {% if object.virtual_chassis %} - - - - - {% endif %} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{% trans "Region" %}{% nested_tree object.site.region %}
{% trans "Site" %}{{ object.site|linkify }}
{% trans "Location" %}{% nested_tree object.location %}
{% trans "Virtual Chassis" %}{{ object.virtual_chassis|linkify }}
{% trans "Rack" %} - {% if object.rack %} - {{ object.rack|linkify }} - - - - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Position" %} - {% if object.parent_bay %} - {% with object.parent_bay.device as parent %} - {{ parent|linkify }} / {{ object.parent_bay }} - {% if parent.position %} - (U{{ parent.position|floatformat }} / {{ parent.get_face_display }}) - {% endif %} - {% endwith %} - {% elif object.rack and object.position %} - U{{ object.position|floatformat }} / {{ object.get_face_display }} - {% elif object.rack and object.device_type.u_height %} - {% trans "Not racked" %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "GPS Coordinates" %} - {% if object.latitude and object.longitude %} - {% if config.MAPS_URL %} - - {% endif %} - {{ object.latitude }}, {{ object.longitude }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Tenant" %} - {% if object.tenant.group %} - {{ object.tenant.group|linkify }} / - {% endif %} - {{ object.tenant|linkify|placeholder }} -
{% trans "Device Type" %} - {{ object.device_type|linkify:"full_name" }} ({{ object.device_type.u_height|floatformat }}U) -
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Airflow" %} - {{ object.get_airflow_display|placeholder }} -
{% trans "Serial Number" %}{{ object.serial|placeholder }}
{% trans "Asset Tag" %}{{ object.asset_tag|placeholder }}
{% trans "Config Template" %}{{ object.config_template|linkify|placeholder }}
-
- {% if vc_members %} -
-

- {% trans "Virtual Chassis" %} - -

- - - - - - - - - - - {% for vc_member in vc_members %} - - - - - - - {% endfor %} - -
{% trans "Device" %}{% trans "Position" %}{% trans "Master" %}{% trans "Priority" %}
{{ vc_member|linkify }}{% badge vc_member.vc_position show_empty=True %} - {% if object.virtual_chassis.master == vc_member %} - {% checkmark True %} - {% else %} - {{ ''|placeholder }} - {% endif %} - {{ vc_member.vc_priority|placeholder }}
-
- {% endif %} - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/comments.html' %} -
-

- {% trans "Virtual Device Contexts" %} - {% if perms.dcim.add_virtualdevicecontext %} - - {% endif %} -

- {% htmx_table 'dcim:virtualdevicecontext_list' device_id=object.pk %} -
- {% plugin_left_page object %} -
-
-
-

{% trans "Management" %}

- - - - - - - - - - - - - - - - - - - - - - - - - - {% if object.cluster %} - - - - - {% endif %} -
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Role" %}{{ object.role|linkify }}
{% trans "Platform" %}{{ object.platform|linkify|placeholder }}
{% trans "Primary IPv4" %} - {% if object.primary_ip4 %} - {{ object.primary_ip4.address.ip }} - {% if object.primary_ip4.nat_inside %} - ({% trans "NAT for" %} {{ object.primary_ip4.nat_inside.address.ip }}) - {% elif object.primary_ip4.nat_outside.exists %} - ({% trans "NAT" %}: {% for nat in object.primary_ip4.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) - {% endif %} - {% copy_content "primary_ip4" %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Primary IPv6" %} - {% if object.primary_ip6 %} - {{ object.primary_ip6.address.ip }} - {% if object.primary_ip6.nat_inside %} - ({% trans "NAT for" %} {{ object.primary_ip6.nat_inside.address.ip }}) - {% elif object.primary_ip6.nat_outside.exists %} - ({% trans "NAT" %}: {% for nat in object.primary_ip6.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) - {% endif %} - {% copy_content "primary_ip6" %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
Out-of-band IP - {% if object.oob_ip %} - {{ object.oob_ip.address.ip }} - {% if object.oob_ip.nat_inside %} - ({% trans "NAT for" %} {{ object.oob_ip.nat_inside.address.ip }}) - {% elif object.oob_ip.nat_outside.exists %} - ({% trans "NAT" %}: {% for nat in object.oob_ip.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) - {% endif %} - {% copy_content "oob_ip" %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Cluster" %} - {% if object.cluster.group %} - {{ object.cluster.group|linkify }} / - {% endif %} - {{ object.cluster|linkify }} -
-
- {% if object.powerports.exists and object.poweroutlets.exists %} -
-

{% trans "Power Utilization" %}

- - - - - - - - - - - {% for powerport in object.powerports.all %} - {% with utilization=powerport.get_power_draw powerfeed=powerport.connected_endpoints.0 %} - - - - - {% if powerfeed.available_power %} - - - {% else %} - - - {% endif %} - - {% for leg in utilization.legs %} - - - - - {% if powerfeed.available_power %} - {% with phase_available=powerfeed.available_power|divide:3 %} - - - {% endwith %} - {% else %} - - - {% endif %} - - {% endfor %} - {% endwith %} - {% endfor %} -
{% trans "Input" %}{% trans "Outlets" %}{% trans "Allocated" %}{% trans "Available" %}{% trans "Utilization" %}
{{ powerport }}{{ utilization.outlet_count }}{{ utilization.allocated }}{% trans "VA" %}{{ powerfeed.available_power }}{% trans "VA" %}{% utilization_graph utilization.allocated|percentage:powerfeed.available_power %}
- {% trans "Leg" context "Leg of a power feed" %} {{ leg.name }} - {{ leg.outlet_count }}{{ leg.allocated }}{{ phase_available }}{% trans "VA" %}{% utilization_graph leg.allocated|percentage:phase_available %}
-
- {% endif %} -
-

- {% trans "Application Services" %} - {% if perms.ipam.add_service %} - - {% endif %} -

- {% htmx_table 'ipam:service_list' device_id=object.pk %} -
- {% include 'inc/panels/image_attachments.html' %} -
-

{% trans "Dimensions" %}

- - - - - - - - - -
{% trans "Height" %} - {{ object.device_type.u_height }}U -
{% trans "Weight" %} - {% if object.total_weight %} - {{ object.total_weight|floatformat }} {% trans "Kilograms" %} - ({{ object.total_weight|kg_to_pounds|floatformat }} {% trans "Pounds" %}) - {% else %} - {{ ''|placeholder }} - {% endif %} -
-
- {% if object.rack and object.position %} -
-
- {{ object.rack.name }} - {% if object.rack.role %} -
{{ object.rack.role }} - {% endif %} - {% if object.rack.facility_id %} -
{{ object.rack.facility_id }} - {% endif %} -
-
-
-

{% trans "Front" %}

- {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' extra_params=svg_extra %} -
-
-
-
-

{% trans "Rear" %}

- {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' extra_params=svg_extra %} -
-
-
- {% endif %} - {% plugin_right_page object %} -
-
-
-
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 909b5276e..4c9c678dc 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -1,112 +1 @@ {% extends 'dcim/devicetype/base.html' %} -{% load buttons %} -{% load helpers %} -{% load plugins %} -{% load i18n %} - -{% block content %} -
-
-
-

{% trans "Chassis" %}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{% trans "Manufacturer" %}{{ object.manufacturer|linkify }}
{% trans "Model Name" %} - {{ object.model }}
- {{ object.slug }} -
{% trans "Part Number" %}{{ object.part_number|placeholder }}
{% trans "Default Platform" %}{{ object.default_platform|linkify }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Height (U)" %}{{ object.u_height|floatformat }}
{% trans "Exclude From Utilization" %}{% checkmark object.exclude_from_utilization %}
{% trans "Full Depth" %}{% checkmark object.is_full_depth %}
{% trans "Weight" %} - {% if object.weight %} - {{ object.weight|floatformat }} {{ object.get_weight_unit_display }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Parent/Child" %} - {{ object.get_subdevice_role_display|placeholder }} -
{% trans "Airflow" %} - {{ object.get_airflow_display|placeholder }} -
{% trans "Front Image" %} - {% if object.front_image %} - - {{ object.front_image.name }} - - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Rear Image" %} - {% if object.rear_image %} - - {{ object.rear_image.name }} - - {% else %} - {{ ''|placeholder }} - {% endif %} -
-
- {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} -
-
- {% include 'inc/panels/related_objects.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/comments.html' %} - {% include 'inc/panels/image_attachments.html' %} - {% plugin_right_page object %} -
-
-
-
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index dfd0c32b3..07c64305a 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -1,8 +1,4 @@ {% extends 'generic/object.html' %} -{% load helpers %} -{% load plugins %} -{% load render_table from django_tables2 %} -{% load i18n %} {% block breadcrumbs %} {{ block.super }} @@ -10,96 +6,3 @@ {% endfor %} {% endblock %} - -{% block extra_controls %} - {% if perms.dcim.add_location %} - - {% trans "Add Child Location" %} - - {% endif %} -{% endblock extra_controls %} - -{% block content %} -
-
-
-

{% trans "Location" %}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Site" %}{{ object.site|linkify }}
{% trans "Parent" %}{{ object.parent|linkify|placeholder }}
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Tenant" %} - {% if object.tenant.group %} - {{ object.tenant.group|linkify }} / - {% endif %} - {{ object.tenant|linkify|placeholder }} -
{% trans "Facility" %}{{ object.facility|placeholder }}
-
- {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/comments.html' %} - {% plugin_left_page object %} -
-
- {% include 'inc/panels/related_objects.html' %} - {% include 'inc/panels/image_attachments.html' %} - {% plugin_right_page object %} -
-
-
-
-
-

- {% trans "Child Locations" %} - {% if perms.dcim.add_location %} - - {% endif %} -

- {% htmx_table 'dcim:location_list' parent_id=object.pk %} -
-
-

- {% trans "Non-Racked Devices" %} - {% if perms.dcim.add_device %} - - {% endif %} -

- {% htmx_table 'dcim:device_list' location_id=object.pk rack_id='null' parent_bay_id='null' %} -
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/manufacturer.html b/netbox/templates/dcim/manufacturer.html index cb6251f63..f6bd7dfc3 100644 --- a/netbox/templates/dcim/manufacturer.html +++ b/netbox/templates/dcim/manufacturer.html @@ -1,7 +1,4 @@ {% extends 'generic/object.html' %} -{% load helpers %} -{% load plugins %} -{% load render_table from django_tables2 %} {% load i18n %} {% block extra_controls %} @@ -25,35 +22,3 @@
{% endif %} {% endblock extra_controls %} - -{% block content %} -
-
-
-

{% trans "Manufacturer" %}

- - - - - - - - - -
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
-
- {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} -
-
- {% include 'inc/panels/related_objects.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% plugin_right_page object %} -
-
-
-
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/moduletypeprofile.html b/netbox/templates/dcim/moduletypeprofile.html deleted file mode 100644 index 87e576bda..000000000 --- a/netbox/templates/dcim/moduletypeprofile.html +++ /dev/null @@ -1,59 +0,0 @@ -{% extends 'generic/object.html' %} -{% load buttons %} -{% load helpers %} -{% load plugins %} -{% load i18n %} - -{% block title %}{{ object.name }}{% endblock %} - -{% block content %} -
-
-
-

{% trans "Module Type Profile" %}

- - - - - - - - - -
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
-
- {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/comments.html' %} - {% plugin_left_page object %} -
-
-
-

- {% trans "Schema" %} - {% copy_content 'profile_schema' %} -

-
{{ object.schema|json }}
-
- {% include 'inc/panels/custom_fields.html' %} - {% plugin_right_page object %} -
-
-
-
-
-

- {% trans "Module Types" %} - {% if perms.dcim.add_moduletype %} - - {% endif %} -

- {% htmx_table 'dcim:moduletype_list' profile_id=object.pk %} -
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/panels/rack_reservation_elevations.html b/netbox/templates/dcim/panels/rack_reservation_elevations.html new file mode 100644 index 000000000..dab57f242 --- /dev/null +++ b/netbox/templates/dcim/panels/rack_reservation_elevations.html @@ -0,0 +1,15 @@ +{% load i18n %} +
+
+
+

{% trans "Front" %}

+ {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' extra_params=svg_extra %} +
+
+
+
+

{% trans "Rear" %}

+ {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' extra_params=svg_extra %} +
+
+
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index eec4d63a5..a8f85e1f1 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -1,153 +1 @@ {% extends 'dcim/rack/base.html' %} -{% load buttons %} -{% load helpers %} -{% load static %} -{% load plugins %} -{% load i18n %} -{% load mptt %} - -{% block content %} -
-
-
-

{% trans "Rack" %}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{% trans "Region" %}{% nested_tree object.site.region %}
{% trans "Site" %}{{ object.site|linkify }}
{% trans "Location" %}{% nested_tree object.location %}
{% trans "Facility ID" %}{{ object.facility_id|placeholder }}
{% trans "Tenant" %} - {% if object.tenant.group %} - {{ object.tenant.group|linkify }} / - {% endif %} - {{ object.tenant|linkify|placeholder }} -
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Rack Type" %}{{ object.rack_type|linkify:"full_name"|placeholder }}
{% trans "Role" %}{{ object.role|linkify|placeholder }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Serial Number" %}{{ object.serial|placeholder }}
{% trans "Asset Tag" %}{{ object.asset_tag|placeholder }}
{% trans "Airflow" %}{{ object.get_airflow_display|placeholder }}
{% trans "Space Utilization" %}{% utilization_graph object.get_utilization %}
{% trans "Power Utilization" %}{% utilization_graph object.get_power_utilization %}
-
- {% include 'dcim/inc/panels/racktype_dimensions.html' %} - {% include 'dcim/inc/panels/racktype_numbering.html' %} -
-

{% trans "Weight" %}

- - - - - - - - - - - - - -
{% trans "Rack Weight" %} - {% if object.weight %} - {{ object.weight|floatformat }} {{ object.get_weight_unit_display }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Maximum Weight" %} - {% if object.max_weight %} - {{ object.max_weight }} {{ object.get_weight_unit_display }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Total Weight" %} - {% if object.total_weight %} - {{ object.total_weight|floatformat }} {% trans "Kilograms" %} - ({{ object.total_weight|kg_to_pounds|floatformat }} {% trans "Pounds" %}) - {% else %} - {{ ''|placeholder }} - {% endif %} -
-
- {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/comments.html' %} - {% include 'inc/panels/image_attachments.html' %} - {% plugin_left_page object %} -
-
-
- -
-
-
-
-

{% trans "Front" %}

- {% include 'dcim/inc/rack_elevation.html' with face='front' extra_params=svg_extra %} -
-
-
-
-

{% trans "Rear" %}

- {% include 'dcim/inc/rack_elevation.html' with face='rear' extra_params=svg_extra %} -
-
-
- {% include 'inc/panels/related_objects.html' %} - {% plugin_right_page object %} -
-
-
-
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/rack/attrs/total_weight.html b/netbox/templates/dcim/rack/attrs/total_weight.html new file mode 100644 index 000000000..2db1d13e8 --- /dev/null +++ b/netbox/templates/dcim/rack/attrs/total_weight.html @@ -0,0 +1,3 @@ +{% load i18n %} +{{ value|floatformat }} {% trans "Kilograms" %} +({{ value|kg_to_pounds|floatformat }} {% trans "Pounds" %}) diff --git a/netbox/templates/dcim/rackreservation.html b/netbox/templates/dcim/rackreservation.html index 87c4f7e4b..405117041 100644 --- a/netbox/templates/dcim/rackreservation.html +++ b/netbox/templates/dcim/rackreservation.html @@ -1,99 +1,8 @@ {% extends 'generic/object.html' %} -{% load buttons %} -{% load helpers %} -{% load static %} -{% load plugins %} {% load i18n %} -{% load mptt %} {% block breadcrumbs %} {{ block.super }} {% endblock %} - -{% block content %} -
-
-
-

{% trans "Rack" %}

- - - - - - - - - - - - - - - - - -
{% trans "Region" %} - {% nested_tree object.rack.site.region %} -
{% trans "Site" %}{{ object.rack.site|linkify }}
{% trans "Location" %}{{ object.rack.location|linkify|placeholder }}
{% trans "Rack" %}{{ object.rack|linkify }}
-
-
-

{% trans "Reservation Details" %}

- - - - - - - - - - - - - - - - - - - - - -
{% trans "Units" %}{{ object.unit_list }}
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Tenant" %} - {% if object.tenant.group %} - {{ object.tenant.group|linkify }} / - {% endif %} - {{ object.tenant|linkify|placeholder }} -
{% trans "User" %}{{ object.user }}
{% trans "Description" %}{{ object.description }}
-
- {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/comments.html' %} - {% plugin_left_page object %} -
-
-
-
-
-

{% trans "Front" %}

- {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' %} -
-
-
-
-

{% trans "Rear" %}

- {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' %} -
-
-
- {% plugin_right_page object %} -
-
-
-
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/rackrole.html b/netbox/templates/dcim/rackrole.html index 87341d90e..5466eea21 100644 --- a/netbox/templates/dcim/rackrole.html +++ b/netbox/templates/dcim/rackrole.html @@ -1,7 +1,4 @@ {% extends 'generic/object.html' %} -{% load helpers %} -{% load plugins %} -{% load render_table from django_tables2 %} {% load i18n %} {% block extra_controls %} @@ -11,41 +8,3 @@ {% endif %} {% endblock extra_controls %} - -{% block content %} -
-
-
-

{% trans "Rack Role" %}

- - - - - - - - - - - - - -
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Color" %} -   -
-
- {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} -
-
- {% include 'inc/panels/related_objects.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% plugin_right_page object %} -
-
-
-
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/racktype.html b/netbox/templates/dcim/racktype.html deleted file mode 100644 index cfeba02fe..000000000 --- a/netbox/templates/dcim/racktype.html +++ /dev/null @@ -1,75 +0,0 @@ -{% extends 'generic/object.html' %} -{% load buttons %} -{% load helpers %} -{% load static %} -{% load plugins %} -{% load i18n %} -{% load mptt %} - -{% block content %} -
-
-
-

{% trans "Rack Type" %}

- - - - - - - - - - - - - - - - - -
{% trans "Manufacturer" %}{{ object.manufacturer|linkify }}
{% trans "Model" %}{{ object.model }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Airflow" %}{{ object.get_airflow_display|placeholder }}
-
- {% include 'dcim/inc/panels/racktype_dimensions.html' %} - {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/comments.html' %} - {% plugin_left_page object %} -
-
- {% include 'dcim/inc/panels/racktype_numbering.html' %} -
-

{% trans "Weight" %}

- - - - - - - - - -
{% trans "Rack Weight" %} - {% if object.weight %} - {{ object.weight|floatformat }} {{ object.get_weight_unit_display }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Maximum Weight" %} - {% if object.max_weight %} - {{ object.max_weight }} {{ object.get_weight_unit_display }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
-
- {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/related_objects.html' %} - {% plugin_right_page object %} -
-
-
-
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html index f11868b0a..8fdf314ae 100644 --- a/netbox/templates/dcim/region.html +++ b/netbox/templates/dcim/region.html @@ -1,7 +1,4 @@ {% extends 'generic/object.html' %} -{% load helpers %} -{% load plugins %} -{% load render_table from django_tables2 %} {% load i18n %} {% block breadcrumbs %} @@ -18,53 +15,3 @@ {% endif %} {% endblock extra_controls %} - -{% block content %} -
-
-
-

{% trans "Region" %}

- - - - - - - - - - - - - -
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Parent" %}{{ object.parent|linkify|placeholder }}
-
- {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/comments.html' %} - {% plugin_left_page object %} -
-
- {% include 'inc/panels/related_objects.html' %} - {% plugin_right_page object %} -
-
-
-
-
-

- {% trans "Child Regions" %} - {% if perms.dcim.add_region %} - - {% endif %} -

- {% htmx_table 'dcim:region_list' parent_id=object.pk %} -
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index cf65961d9..3cefe59b1 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -1,10 +1,4 @@ {% extends 'generic/object.html' %} -{% load helpers %} -{% load plugins %} -{% load tz %} -{% load i18n %} -{% load l10n %} -{% load mptt %} {% block breadcrumbs %} {{ block.super }} @@ -20,135 +14,3 @@ {% endif %} {% endblock %} - -{% block content %} -
-
-
-

{% trans "Site" %}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{% trans "Region" %} - {% nested_tree object.region %} -
{% trans "Group" %} - {% nested_tree object.group %} -
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Tenant" %} - {% if object.tenant.group %} - {{ object.tenant.group|linkify }} / - {% endif %} - {{ object.tenant|linkify|placeholder }} -
{% trans "Facility" %}{{ object.facility|placeholder }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Time Zone" %} - {% if object.time_zone %} - {{ object.time_zone }} ({% trans "UTC" %} {{ object.time_zone|tzoffset }})
- {% trans "Site time" %}: {% timezone object.time_zone %}{% now 'Y-m-d H:i' %}{% endtimezone %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Physical Address" %} - {% if object.physical_address %} - {{ object.physical_address|linebreaksbr }} - {% if config.MAPS_URL %} - - {% trans "Map" %} - - {% endif %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Shipping Address" %}{{ object.shipping_address|linebreaksbr|placeholder }}
{% trans "GPS Coordinates" %} - {% if object.latitude and object.longitude %} - {% if config.MAPS_URL %} - - {% endif %} - {{ object.latitude }}, {{ object.longitude }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
-
- {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/comments.html' %} - {% plugin_left_page object %} -
-
- {% include 'inc/panels/related_objects.html' with filter_name='site_id' %} - {% include 'inc/panels/image_attachments.html' %} - {% plugin_right_page object %} -
-
-
-
-
-

- {% trans "Locations" %} - {% if perms.dcim.add_location %} - - {% endif %} -

- {% htmx_table 'dcim:location_list' site_id=object.pk %} -
-
-

- {% trans "Non-Racked Devices" %} - {% if perms.dcim.add_device %} - - {% endif %} -

- {% htmx_table 'dcim:device_list' site_id=object.pk rack_id='null' parent_bay_id='null' %} -
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html index dc9aca6f5..b25156ea2 100644 --- a/netbox/templates/dcim/sitegroup.html +++ b/netbox/templates/dcim/sitegroup.html @@ -1,7 +1,4 @@ {% extends 'generic/object.html' %} -{% load helpers %} -{% load plugins %} -{% load render_table from django_tables2 %} {% load i18n %} {% block breadcrumbs %} @@ -18,53 +15,3 @@ {% endif %} {% endblock extra_controls %} - -{% block content %} -
-
-
-

{% trans "Site Group" %}

- - - - - - - - - - - - - -
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Parent" %}{{ object.parent|linkify|placeholder }}
-
- {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/comments.html' %} - {% plugin_left_page object %} -
-
- {% include 'inc/panels/related_objects.html' %} - {% plugin_right_page object %} -
-
-
-
-
-

- {% trans "Child Groups" %} - {% if perms.dcim.add_sitegroup %} - - {% endif %} -

- {% htmx_table 'dcim:sitegroup_list' parent_id=object.pk %} -
- {% plugin_full_width_page object %} -
-
-{% endblock %} From 3e4322690132d64e315c6cdea21f8558a938a83c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 7 Nov 2025 16:31:25 -0500 Subject: [PATCH 078/125] Annotate begin & end of panels in HTML --- netbox/netbox/ui/panels.py | 1 + netbox/templates/ui/panels/_base.html | 2 ++ 2 files changed, 3 insertions(+) diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index e62d9f422..4d16cb8d3 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -65,6 +65,7 @@ class Panel: 'object': context.get('object'), 'title': self.title, 'actions': self.actions, + 'panel_class': self.__class__.__name__, } def render(self, context): diff --git a/netbox/templates/ui/panels/_base.html b/netbox/templates/ui/panels/_base.html index 1f11b020f..5128e6428 100644 --- a/netbox/templates/ui/panels/_base.html +++ b/netbox/templates/ui/panels/_base.html @@ -1,3 +1,4 @@ +

{{ title }} @@ -11,3 +12,4 @@

{% block panel_content %}{% endblock %}
+ From 6e7bbfc3e2401d9d21e974b019d2dbb3d1fbf8e3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 10 Nov 2025 08:35:33 -0500 Subject: [PATCH 079/125] Fix templates --- netbox/templates/dcim/moduletypeprofile.html | 1 + netbox/templates/dcim/rack/attrs/total_weight.html | 2 +- netbox/templates/dcim/racktype.html | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 netbox/templates/dcim/moduletypeprofile.html create mode 100644 netbox/templates/dcim/racktype.html diff --git a/netbox/templates/dcim/moduletypeprofile.html b/netbox/templates/dcim/moduletypeprofile.html new file mode 100644 index 000000000..f15e1d050 --- /dev/null +++ b/netbox/templates/dcim/moduletypeprofile.html @@ -0,0 +1 @@ +{% extends 'generic/object.html' %} diff --git a/netbox/templates/dcim/rack/attrs/total_weight.html b/netbox/templates/dcim/rack/attrs/total_weight.html index 2db1d13e8..73ac54ef5 100644 --- a/netbox/templates/dcim/rack/attrs/total_weight.html +++ b/netbox/templates/dcim/rack/attrs/total_weight.html @@ -1,3 +1,3 @@ -{% load i18n %} +{% load helpers i18n %} {{ value|floatformat }} {% trans "Kilograms" %} ({{ value|kg_to_pounds|floatformat }} {% trans "Pounds" %}) diff --git a/netbox/templates/dcim/racktype.html b/netbox/templates/dcim/racktype.html new file mode 100644 index 000000000..f15e1d050 --- /dev/null +++ b/netbox/templates/dcim/racktype.html @@ -0,0 +1 @@ +{% extends 'generic/object.html' %} From 01cbdbb968705adb601c1a788ba5cd716a7a60a9 Mon Sep 17 00:00:00 2001 From: RobertH1993 Date: Wed, 12 Nov 2025 20:59:01 +0100 Subject: [PATCH 080/125] Closes #18658: Add start on boot field to VirtualMachine model (#20751) --- docs/models/virtualization/virtualmachine.md | 7 +++++++ .../virtualization/virtualmachine.html | 4 ++++ .../api/serializers_/virtualmachines.py | 17 +++++++++-------- netbox/virtualization/choices.py | 14 ++++++++++++++ netbox/virtualization/filtersets.py | 4 ++++ netbox/virtualization/forms/bulk_edit.py | 8 +++++++- netbox/virtualization/forms/bulk_import.py | 10 ++++++++-- netbox/virtualization/forms/filtersets.py | 7 ++++++- netbox/virtualization/forms/model_forms.py | 8 ++++---- .../0050_virtualmachine_start_on_boot.py | 18 ++++++++++++++++++ .../virtualization/models/virtualmachines.py | 9 +++++++++ .../virtualization/tables/virtualmachines.py | 9 ++++++--- netbox/virtualization/tests/test_api.py | 4 +++- netbox/virtualization/tests/test_filtersets.py | 10 ++++++++-- netbox/virtualization/tests/test_views.py | 2 ++ 15 files changed, 109 insertions(+), 22 deletions(-) create mode 100644 netbox/virtualization/migrations/0050_virtualmachine_start_on_boot.py diff --git a/docs/models/virtualization/virtualmachine.md b/docs/models/virtualization/virtualmachine.md index a90b2752d..189a4ba75 100644 --- a/docs/models/virtualization/virtualmachine.md +++ b/docs/models/virtualization/virtualmachine.md @@ -21,6 +21,13 @@ The VM's operational status. !!! tip Additional statuses may be defined by setting `VirtualMachine.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. +### Start on boot + +The start on boot setting from the hypervisor. + +!!! tip + Additional statuses may be defined by setting `VirtualMachine.start_on_boot` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. + ### Site & Cluster The [site](../dcim/site.md) and/or [cluster](./cluster.md) to which the VM is assigned. diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 7f902a20d..1ee566eb0 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -19,6 +19,10 @@ {% trans "Status" %} {% badge object.get_status_display bg_color=object.get_status_color %} + + {% trans "Start on boot" %} + {% badge object.get_start_on_boot_display bg_color=object.get_start_on_boot_color %} + {% trans "Role" %} {{ object.role|linkify|placeholder }} diff --git a/netbox/virtualization/api/serializers_/virtualmachines.py b/netbox/virtualization/api/serializers_/virtualmachines.py index c035a436a..25030eaf3 100644 --- a/netbox/virtualization/api/serializers_/virtualmachines.py +++ b/netbox/virtualization/api/serializers_/virtualmachines.py @@ -31,6 +31,7 @@ __all__ = ( class VirtualMachineSerializer(PrimaryModelSerializer): status = ChoiceField(choices=VirtualMachineStatusChoices, required=False) + start_on_boot = ChoiceField(choices=VirtualMachineStartOnBootChoices, required=False) site = SiteSerializer(nested=True, required=False, allow_null=True, default=None) cluster = ClusterSerializer(nested=True, required=False, allow_null=True, default=None) device = DeviceSerializer(nested=True, required=False, allow_null=True, default=None) @@ -49,10 +50,10 @@ class VirtualMachineSerializer(PrimaryModelSerializer): class Meta: model = VirtualMachine fields = [ - 'id', 'url', 'display_url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'serial', 'role', - 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', - 'owner', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', - 'last_updated', 'interface_count', 'virtual_disk_count', + 'id', 'url', 'display_url', 'display', 'name', 'status', 'start_on_boot', 'site', 'cluster', 'device', + 'serial', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', + 'disk', 'description', 'owner', 'comments', 'config_template', 'local_context_data', 'tags', + 'custom_fields', 'created', 'last_updated', 'interface_count', 'virtual_disk_count', ] brief_fields = ('id', 'url', 'display', 'name', 'description') @@ -62,10 +63,10 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): class Meta(VirtualMachineSerializer.Meta): fields = [ - 'id', 'url', 'display_url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'serial', 'role', - 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', - 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', - 'last_updated', 'interface_count', 'virtual_disk_count', + 'id', 'url', 'display_url', 'display', 'name', 'status', 'start_on_boot', 'site', 'cluster', 'device', + 'serial', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', + 'disk', 'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', + 'config_context', 'created', 'last_updated', 'interface_count', 'virtual_disk_count', ] @extend_schema_field(serializers.JSONField(allow_null=True)) diff --git a/netbox/virtualization/choices.py b/netbox/virtualization/choices.py index b60a6e1ff..b00c9f1e2 100644 --- a/netbox/virtualization/choices.py +++ b/netbox/virtualization/choices.py @@ -49,3 +49,17 @@ class VirtualMachineStatusChoices(ChoiceSet): (STATUS_DECOMMISSIONING, _('Decommissioning'), 'yellow'), (STATUS_PAUSED, _('Paused'), 'orange'), ] + + +class VirtualMachineStartOnBootChoices(ChoiceSet): + key = 'VirtualMachine.start_on_boot' + + STATUS_ON = 'on' + STATUS_OFF = 'off' + STATUS_LAST_STATE = 'laststate' + + CHOICES = [ + (STATUS_ON, _('On'), 'green'), + (STATUS_OFF, _('Off'), 'gray'), + (STATUS_LAST_STATE, _('Last State'), 'cyan') + ] diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index e2ef8cb6a..b96f1dc24 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -92,6 +92,10 @@ class VirtualMachineFilterSet( choices=VirtualMachineStatusChoices, null_value=None ) + start_on_boot = django_filters.MultipleChoiceFilter( + choices=VirtualMachineStartOnBootChoices, + null_value=None + ) cluster_group_id = django_filters.ModelMultipleChoiceFilter( field_name='cluster__group', queryset=ClusterGroup.objects.all(), diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index 092bf576b..b8a7f0c10 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -85,6 +85,12 @@ class VirtualMachineBulkEditForm(PrimaryModelBulkEditForm): required=False, initial='', ) + start_on_boot = forms.ChoiceField( + label=_('Start on boot'), + choices=add_blank_choice(VirtualMachineStartOnBootChoices), + required=False, + initial='', + ) site = DynamicModelChoiceField( label=_('Site'), queryset=Site.objects.all(), @@ -145,7 +151,7 @@ class VirtualMachineBulkEditForm(PrimaryModelBulkEditForm): model = VirtualMachine fieldsets = ( - FieldSet('site', 'cluster', 'device', 'status', 'role', 'tenant', 'platform', 'description'), + FieldSet('site', 'cluster', 'device', 'status', 'start_on_boot', 'role', 'tenant', 'platform', 'description'), FieldSet('vcpus', 'memory', 'disk', name=_('Resources')), FieldSet('config_template', name=_('Configuration')), ) diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index c423651e2..165122962 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -89,6 +89,12 @@ class VirtualMachineImportForm(PrimaryModelImportForm): choices=VirtualMachineStatusChoices, help_text=_('Operational status') ) + start_on_boot = CSVChoiceField( + label=_('Start on boot'), + choices=VirtualMachineStartOnBootChoices, + help_text=_('Start on boot in hypervisor'), + required=False, + ) site = CSVModelChoiceField( label=_('Site'), queryset=Site.objects.all(), @@ -144,8 +150,8 @@ class VirtualMachineImportForm(PrimaryModelImportForm): class Meta: model = VirtualMachine fields = ( - 'name', 'status', 'role', 'site', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk', - 'description', 'serial', 'config_template', 'comments', 'owner', 'tags', + 'name', 'status', 'start_on_boot', 'role', 'site', 'cluster', 'device', 'tenant', 'platform', 'vcpus', + 'memory', 'disk', 'description', 'serial', 'config_template', 'comments', 'owner', 'tags', ) diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 3e0db175e..27fda4a85 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -109,7 +109,7 @@ class VirtualMachineFilterForm( FieldSet('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id', name=_('Cluster')), FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), FieldSet( - 'status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'config_template_id', + 'status', 'start_on_boot', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'config_template_id', 'local_context_data', 'serial', name=_('Attributes') ), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), @@ -171,6 +171,11 @@ class VirtualMachineFilterForm( choices=VirtualMachineStatusChoices, required=False ) + start_on_boot = forms.MultipleChoiceField( + label=_('Start on boot'), + choices=VirtualMachineStartOnBootChoices, + required=False + ) platform_id = DynamicModelMultipleChoiceField( queryset=Platform.objects.all(), required=False, diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index fa4966b2b..e3ba36bad 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -217,7 +217,7 @@ class VirtualMachineForm(TenancyForm, PrimaryModelForm): ) fieldsets = ( - FieldSet('name', 'role', 'status', 'description', 'serial', 'tags', name=_('Virtual Machine')), + FieldSet('name', 'role', 'status', 'start_on_boot', 'description', 'serial', 'tags', name=_('Virtual Machine')), FieldSet('site', 'cluster', 'device', name=_('Site/Cluster')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), FieldSet('platform', 'primary_ip4', 'primary_ip6', 'config_template', name=_('Management')), @@ -228,9 +228,9 @@ class VirtualMachineForm(TenancyForm, PrimaryModelForm): class Meta: model = VirtualMachine fields = [ - 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4', - 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'serial', 'owner', 'comments', 'tags', - 'local_context_data', 'config_template', + 'name', 'status', 'start_on_boot', 'site', 'cluster', 'device', 'role', 'tenant_group', 'tenant', + 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'serial', 'owner', + 'comments', 'tags', 'local_context_data', 'config_template', ] def __init__(self, *args, **kwargs): diff --git a/netbox/virtualization/migrations/0050_virtualmachine_start_on_boot.py b/netbox/virtualization/migrations/0050_virtualmachine_start_on_boot.py new file mode 100644 index 000000000..899cb28a8 --- /dev/null +++ b/netbox/virtualization/migrations/0050_virtualmachine_start_on_boot.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2025-11-05 13:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0049_owner'), + ] + + operations = [ + migrations.AddField( + model_name='virtualmachine', + name='start_on_boot', + field=models.CharField(default='off', max_length=32), + ), + ] diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py index de6fde745..f4679c9c2 100644 --- a/netbox/virtualization/models/virtualmachines.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -79,6 +79,12 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co default=VirtualMachineStatusChoices.STATUS_ACTIVE, verbose_name=_('status') ) + start_on_boot = models.CharField( + max_length=32, + choices=VirtualMachineStartOnBootChoices, + default=VirtualMachineStartOnBootChoices.STATUS_OFF, + verbose_name=_('start on boot'), + ) role = models.ForeignKey( to='dcim.DeviceRole', on_delete=models.PROTECT, @@ -247,6 +253,9 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co def get_status_color(self): return VirtualMachineStatusChoices.colors.get(self.status) + def get_start_on_boot_color(self): + return VirtualMachineStartOnBootChoices.colors.get(self.start_on_boot) + @property def primary_ip(self): if get_config().PREFER_IPV4 and self.primary_ip4: diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index fcb9017df..c770581d0 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -29,6 +29,9 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModel status = columns.ChoiceFieldColumn( verbose_name=_('Status'), ) + start_on_boot = columns.ChoiceFieldColumn( + verbose_name=_('Start on boot'), + ) site = tables.Column( verbose_name=_('Site'), linkify=True @@ -81,9 +84,9 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModel class Meta(PrimaryModelTable.Meta): model = VirtualMachine fields = ( - 'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'tenant_group', 'vcpus', - 'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'description', 'comments', 'config_template', - 'serial', 'contacts', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'status', 'start_on_boot', 'site', 'cluster', 'device', 'role', 'tenant', + 'tenant_group', 'vcpus', 'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'description', + 'comments', 'config_template', 'serial', 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip', diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 56f9132ab..40c4df2ba 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -211,7 +211,8 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): name='Virtual Machine 3', site=sites[0], cluster=clusters[0], - local_context_data={'C': 3} + local_context_data={'C': 3}, + start_on_boot=VirtualMachineStartOnBootChoices.STATUS_ON, ), ) VirtualMachine.objects.bulk_create(virtual_machines) @@ -235,6 +236,7 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): { 'name': 'Virtual Machine 7', 'cluster': clusters[2].pk, + 'start_on_boot': VirtualMachineStartOnBootChoices.STATUS_ON, }, ] diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index 0179069af..13a007c15 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -349,7 +349,8 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): memory=2, disk=2, description='foobar2', - serial='222-bbb' + serial='222-bbb', + start_on_boot=VirtualMachineStartOnBootChoices.STATUS_OFF, ), VirtualMachine( name='Virtual Machine 3', @@ -363,7 +364,8 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): vcpus=3, memory=3, disk=3, - description='foobar3' + description='foobar3', + start_on_boot=VirtualMachineStartOnBootChoices.STATUS_ON, ), ) VirtualMachine.objects.bulk_create(vms) @@ -430,6 +432,10 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'status': [VirtualMachineStatusChoices.STATUS_ACTIVE, VirtualMachineStatusChoices.STATUS_STAGED]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_start_on_boot(self): + params = {'start_on_boot': [VirtualMachineStartOnBootChoices.STATUS_ON]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_cluster_group(self): groups = ClusterGroup.objects.all()[:2] params = {'cluster_group_id': [groups[0].pk, groups[1].pk]} diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 0c1d2b53a..1f5caae66 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -271,6 +271,7 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'platform': platforms[1].pk, 'name': 'Virtual Machine X', 'status': VirtualMachineStatusChoices.STATUS_STAGED, + 'start_on_boot': VirtualMachineStartOnBootChoices.STATUS_ON, 'role': roles[1].pk, 'primary_ip4': None, 'primary_ip6': None, @@ -309,6 +310,7 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'memory': 65535, 'disk': 8000, 'comments': 'New comments', + 'start_on_boot': VirtualMachineStartOnBootChoices.STATUS_OFF, } @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) From cee2a5e0ed17877cea9e5799c2a878b72ba052e2 Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Thu, 13 Nov 2025 17:17:39 +0100 Subject: [PATCH 081/125] feat(dcim): Add device, module and rack count filters Introduces `device_count`, `module_count` and `rack_count` filters to enable queries based on the existence and count of the associated device, module or rack instances. Updates forms, filtersets, and GraphQL schema to support these filters, along with tests for validation. Fixes #19523 --- netbox/dcim/api/serializers_/devicetypes.py | 11 ++-- netbox/dcim/api/serializers_/racks.py | 9 ++- netbox/dcim/apps.py | 4 +- netbox/dcim/filtersets.py | 11 +++- netbox/dcim/forms/filtersets.py | 25 ++++++- netbox/dcim/graphql/filters.py | 12 +++- netbox/dcim/graphql/types.py | 3 + .../0218_devicetype_device_count.py | 66 +++++++++++++++++++ netbox/dcim/models/devices.py | 4 ++ netbox/dcim/models/modules.py | 8 ++- netbox/dcim/models/racks.py | 17 +++-- netbox/dcim/tables/devicetypes.py | 8 +-- netbox/dcim/tables/modules.py | 8 +-- netbox/dcim/tables/racks.py | 8 +-- netbox/dcim/tests/test_api.py | 4 +- netbox/dcim/views.py | 20 ++---- netbox/utilities/tests/test_counters.py | 41 ++++++++++-- 17 files changed, 202 insertions(+), 57 deletions(-) create mode 100644 netbox/dcim/migrations/0218_devicetype_device_count.py diff --git a/netbox/dcim/api/serializers_/devicetypes.py b/netbox/dcim/api/serializers_/devicetypes.py index 59753847c..797d31d87 100644 --- a/netbox/dcim/api/serializers_/devicetypes.py +++ b/netbox/dcim/api/serializers_/devicetypes.py @@ -5,7 +5,7 @@ from rest_framework import serializers from dcim.choices import * from dcim.models import DeviceType, ModuleType, ModuleTypeProfile -from netbox.api.fields import AttributesField, ChoiceField, RelatedObjectCountField +from netbox.api.fields import AttributesField, ChoiceField from netbox.api.serializers import PrimaryModelSerializer from netbox.choices import * from .manufacturers import ManufacturerSerializer @@ -45,9 +45,7 @@ class DeviceTypeSerializer(PrimaryModelSerializer): device_bay_template_count = serializers.IntegerField(read_only=True) module_bay_template_count = serializers.IntegerField(read_only=True) inventory_item_template_count = serializers.IntegerField(read_only=True) - - # Related object counts - device_count = RelatedObjectCountField('instances') + device_count = serializers.IntegerField(read_only=True) class Meta: model = DeviceType @@ -100,12 +98,13 @@ class ModuleTypeSerializer(PrimaryModelSerializer): required=False, allow_null=True ) + module_count = serializers.IntegerField(read_only=True) class Meta: model = ModuleType fields = [ 'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description', 'attributes', 'owner', 'comments', 'tags', 'custom_fields', - 'created', 'last_updated', + 'created', 'last_updated', 'module_count', ] - brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description') + brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description', 'module_count') diff --git a/netbox/dcim/api/serializers_/racks.py b/netbox/dcim/api/serializers_/racks.py index ef06dc5aa..503f7bee3 100644 --- a/netbox/dcim/api/serializers_/racks.py +++ b/netbox/dcim/api/serializers_/racks.py @@ -62,9 +62,8 @@ class RackBaseSerializer(PrimaryModelSerializer): class RackTypeSerializer(RackBaseSerializer): - manufacturer = ManufacturerSerializer( - nested=True - ) + manufacturer = ManufacturerSerializer(nested=True) + rack_count = serializers.IntegerField(read_only=True) class Meta: model = RackType @@ -72,9 +71,9 @@ class RackTypeSerializer(RackBaseSerializer): 'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'slug', 'description', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'weight', 'max_weight', 'weight_unit', 'mounting_depth', 'description', 'owner', 'comments', - 'tags', 'custom_fields', 'created', 'last_updated', + 'tags', 'custom_fields', 'created', 'last_updated', 'rack_count', ] - brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description') + brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'rack_count') class RackSerializer(RackBaseSerializer): diff --git a/netbox/dcim/apps.py b/netbox/dcim/apps.py index 9653d3b93..67ff17489 100644 --- a/netbox/dcim/apps.py +++ b/netbox/dcim/apps.py @@ -11,7 +11,7 @@ class DCIMConfig(AppConfig): from netbox.models.features import register_models from utilities.counters import connect_counters from . import signals, search # noqa: F401 - from .models import CableTermination, Device, DeviceType, VirtualChassis + from .models import CableTermination, Device, DeviceType, ModuleType, RackType, VirtualChassis # Register models register_models(*self.get_models()) @@ -31,4 +31,4 @@ class DCIMConfig(AppConfig): }) # Register counters - connect_counters(Device, DeviceType, VirtualChassis) + connect_counters(Device, DeviceType, ModuleType, RackType, VirtualChassis) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 6c380c9f4..0fd7631ac 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -317,6 +317,9 @@ class RackTypeFilterSet(PrimaryModelFilterSet): fields = ( 'id', 'model', 'slug', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', + + # Counters + 'rack_count', ) def search(self, queryset, name, value): @@ -627,6 +630,7 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet): 'device_bay_template_count', 'module_bay_template_count', 'inventory_item_template_count', + 'device_count', ) def search(self, queryset, name, value): @@ -747,7 +751,12 @@ class ModuleTypeFilterSet(AttributeFiltersMixin, PrimaryModelFilterSet): class Meta: model = ModuleType - fields = ('id', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description') + fields = ( + 'id', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description', + + # Counters + 'module_count', + ) def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 157cb64f9..1197002a5 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -317,7 +317,7 @@ class RackTypeFilterForm(RackBaseFilterForm): model = RackType fieldsets = ( FieldSet('q', 'filter_id', 'tag', 'owner_id'), - FieldSet('manufacturer_id', 'form_factor', 'width', 'u_height', name=_('Rack Type')), + FieldSet('manufacturer_id', 'form_factor', 'width', 'u_height', 'rack_count', name=_('Rack Type')), FieldSet('starting_unit', 'desc_units', name=_('Numbering')), FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')), ) @@ -327,6 +327,11 @@ class RackTypeFilterForm(RackBaseFilterForm): required=False, label=_('Manufacturer') ) + rack_count = forms.IntegerField( + label=_('Rack count'), + required=False, + min_value=0, + ) tag = TagFilterField(model) @@ -498,7 +503,8 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm): fieldsets = ( FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet( - 'manufacturer_id', 'default_platform_id', 'part_number', 'subdevice_role', 'airflow', name=_('Hardware') + 'manufacturer_id', 'default_platform_id', 'part_number', 'device_count', + 'subdevice_role', 'airflow', name=_('Hardware') ), FieldSet('has_front_image', 'has_rear_image', name=_('Images')), FieldSet( @@ -522,6 +528,11 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm): label=_('Part number'), required=False ) + device_count = forms.IntegerField( + label=_('Device count'), + required=False, + min_value=0, + ) subdevice_role = forms.MultipleChoiceField( label=_('Subdevice role'), choices=add_blank_choice(SubdeviceRoleChoices), @@ -633,7 +644,10 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm): model = ModuleType fieldsets = ( FieldSet('q', 'filter_id', 'tag', 'owner_id'), - FieldSet('profile_id', 'manufacturer_id', 'part_number', 'airflow', name=_('Hardware')), + FieldSet( + 'profile_id', 'manufacturer_id', 'part_number', 'module_count', + 'airflow', name=_('Hardware') + ), FieldSet( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', name=_('Components') @@ -655,6 +669,11 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm): label=_('Part number'), required=False ) + module_count = forms.IntegerField( + label=_('Module count'), + required=False, + min_value=0, + ) console_ports = forms.NullBooleanField( required=False, label=_('Has console ports'), diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py index ccf4a2d98..111902dd9 100644 --- a/netbox/dcim/graphql/filters.py +++ b/netbox/dcim/graphql/filters.py @@ -4,7 +4,7 @@ from django.db.models import Q import strawberry import strawberry_django from strawberry.scalars import ID -from strawberry_django import FilterLookup +from strawberry_django import ComparisonFilterLookup, FilterLookup from core.graphql.filter_mixins import ChangeLogFilterMixin from dcim import models @@ -328,6 +328,9 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig ) default_platform_id: ID | None = strawberry_django.filter_field() part_number: FilterLookup[str] | None = strawberry_django.filter_field() + instances: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) u_height: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() ) @@ -385,6 +388,7 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig device_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field() module_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field() inventory_item_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + device_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field() @strawberry_django.filter_type(models.FrontPort, lookups=True) @@ -685,6 +689,9 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig profile_id: ID | None = strawberry_django.filter_field() model: FilterLookup[str] | None = strawberry_django.filter_field() part_number: FilterLookup[str] | None = strawberry_django.filter_field() + instances: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) airflow: Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( strawberry_django.filter_field() ) @@ -718,6 +725,7 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig inventory_item_templates: ( Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None ) = strawberry_django.filter_field() + module_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field() @strawberry_django.filter_type(models.Platform, lookups=True) @@ -846,6 +854,8 @@ class RackTypeFilter(RackBaseFilterMixin): manufacturer_id: ID | None = strawberry_django.filter_field() model: FilterLookup[str] | None = strawberry_django.filter_field() slug: FilterLookup[str] | None = strawberry_django.filter_field() + racks: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + rack_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field() @strawberry_django.filter_type(models.Rack, lookups=True) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 09502554c..13408dc90 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -358,6 +358,7 @@ class DeviceTypeType(PrimaryObjectType): device_bay_template_count: BigInt module_bay_template_count: BigInt inventory_item_template_count: BigInt + device_count: BigInt front_image: strawberry_django.fields.types.DjangoImageType | None rear_image: strawberry_django.fields.types.DjangoImageType | None manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] @@ -605,6 +606,7 @@ class ModuleTypeProfileType(PrimaryObjectType): pagination=True ) class ModuleTypeType(PrimaryObjectType): + module_count: BigInt profile: Annotated["ModuleTypeProfileType", strawberry.lazy('dcim.graphql.types')] | None manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] @@ -709,6 +711,7 @@ class PowerPortTemplateType(ModularComponentTemplateType): pagination=True ) class RackTypeType(PrimaryObjectType): + rack_count: BigInt manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] diff --git a/netbox/dcim/migrations/0218_devicetype_device_count.py b/netbox/dcim/migrations/0218_devicetype_device_count.py new file mode 100644 index 000000000..7a9a135b1 --- /dev/null +++ b/netbox/dcim/migrations/0218_devicetype_device_count.py @@ -0,0 +1,66 @@ +import utilities.fields +from django.db import migrations +from django.db.models import Count, OuterRef, Subquery + + +def _populate_count_for_type( + apps, schema_editor, app_name: str, model_name: str, target_field: str, related_name: str = 'instances' +): + """ + Update a CounterCache field on the specified model by annotating the count of related instances. + """ + Model = apps.get_model(app_name, model_name) + db_alias = schema_editor.connection.alias + + count_subquery = ( + Model.objects.using(db_alias) + .filter(pk=OuterRef('pk')) + .annotate(_instance_count=Count(related_name)) + .values('_instance_count') + ) + Model.objects.using(db_alias).update(**{target_field: Subquery(count_subquery)}) + + +def populate_device_type_device_count(apps, schema_editor): + _populate_count_for_type(apps, schema_editor, 'dcim', 'DeviceType', 'device_count') + + +def populate_module_type_module_count(apps, schema_editor): + _populate_count_for_type(apps, schema_editor, 'dcim', 'ModuleType', 'module_count') + + +def populate_rack_type_rack_count(apps, schema_editor): + _populate_count_for_type(apps, schema_editor, 'dcim', 'RackType', 'rack_count', related_name='racks') + + +class Migration(migrations.Migration): + dependencies = [ + ('dcim', '0217_owner'), + ] + + operations = [ + migrations.AddField( + model_name='devicetype', + name='device_count', + field=utilities.fields.CounterCacheField( + default=0, editable=False, to_field='device_type', to_model='dcim.Device' + ), + ), + migrations.RunPython(populate_device_type_device_count, migrations.RunPython.noop), + migrations.AddField( + model_name='moduletype', + name='module_count', + field=utilities.fields.CounterCacheField( + default=0, editable=False, to_field='module_type', to_model='dcim.Module' + ), + ), + migrations.RunPython(populate_module_type_module_count, migrations.RunPython.noop), + migrations.AddField( + model_name='racktype', + name='rack_count', + field=utilities.fields.CounterCacheField( + default=0, editable=False, to_field='rack_type', to_model='dcim.Rack' + ), + ), + migrations.RunPython(populate_rack_type_rack_count, migrations.RunPython.noop), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 845cc68d2..dc3146161 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -185,6 +185,10 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): to_model='dcim.InventoryItemTemplate', to_field='device_type' ) + device_count = CounterCacheField( + to_model='dcim.Device', + to_field='device_type' + ) clone_fields = ( 'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', diff --git a/netbox/dcim/models/modules.py b/netbox/dcim/models/modules.py index 4376f40aa..4d26e3261 100644 --- a/netbox/dcim/models/modules.py +++ b/netbox/dcim/models/modules.py @@ -13,8 +13,10 @@ from extras.models import ConfigContextModel, CustomField from netbox.models import PrimaryModel from netbox.models.features import ImageAttachmentsMixin from netbox.models.mixins import WeightMixin +from utilities.fields import CounterCacheField from utilities.jsonschema import validate_schema from utilities.string import title +from utilities.tracking import TrackingModelMixin from .device_components import * __all__ = ( @@ -92,6 +94,10 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): null=True, verbose_name=_('attributes') ) + module_count = CounterCacheField( + to_model='dcim.Module', + to_field='module_type' + ) clone_fields = ('profile', 'manufacturer', 'weight', 'weight_unit', 'airflow') prerequisite_models = ( @@ -186,7 +192,7 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): return yaml.dump(dict(data), sort_keys=False) -class Module(PrimaryModel, ConfigContextModel): +class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel): """ A Module represents a field-installable component within a Device which may itself hold multiple device components (for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes. diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 02bce2019..d7afb7896 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -19,9 +19,11 @@ from netbox.models.mixins import WeightMixin from netbox.models.features import ContactsMixin, ImageAttachmentsMixin from utilities.conversion import to_grams from utilities.data import array_to_string, drange -from utilities.fields import ColorField +from utilities.fields import ColorField, CounterCacheField +from utilities.tracking import TrackingModelMixin from .device_components import PowerPort -from .devices import Device, Module +from .devices import Device +from .modules import Module from .power import PowerFeed __all__ = ( @@ -144,6 +146,10 @@ class RackType(RackBase): max_length=100, unique=True ) + rack_count = CounterCacheField( + to_model='dcim.Rack', + to_field='rack_type' + ) clone_fields = ( 'manufacturer', 'form_factor', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', @@ -234,7 +240,7 @@ class RackRole(OrganizationalModel): verbose_name_plural = _('rack roles') -class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): +class Rack(ContactsMixin, ImageAttachmentsMixin, TrackingModelMixin, RackBase): """ Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. Each Rack is assigned to a Site and (optionally) a Location. @@ -509,7 +515,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): return [u for u in elevation.values()] - def get_available_units(self, u_height=1, rack_face=None, exclude=None, ignore_excluded_devices=False): + def get_available_units(self, u_height=1.0, rack_face=None, exclude=None, ignore_excluded_devices=False): """ Return a list of units within the rack available to accommodate a device of a given U height (default 1). Optionally exclude one or more devices when calculating empty units (needed when moving a device from one @@ -581,9 +587,10 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): :param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total height of the elevation :param legend_width: Width of the unit legend, in pixels - :param margin_width: Width of the rigth-hand margin, in pixels + :param margin_width: Width of the right-hand margin, in pixels :param include_images: Embed front/rear device images where available :param base_url: Base URL for links and images. If none, URLs will be relative. + :param highlight_params: Dictionary of parameters to be passed to the RackElevationSVG.render_highlight() method """ elevation = RackElevationSVG( self, diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 07afe5da2..979689b75 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -109,10 +109,10 @@ class DeviceTypeTable(PrimaryModelTable): template_code=WEIGHT, order_by=('_abs_weight', 'weight_unit') ) - instance_count = columns.LinkedCountColumn( + device_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'device_type_id': 'pk'}, - verbose_name=_('Instances') + verbose_name=_('Device Count'), ) console_port_template_count = tables.Column( verbose_name=_('Console Ports') @@ -150,10 +150,10 @@ class DeviceTypeTable(PrimaryModelTable): fields = ( 'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', - 'description', 'comments', 'instance_count', 'tags', 'created', 'last_updated', + 'description', 'comments', 'device_count', 'tags', 'created', 'last_updated', ) default_columns = ( - 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count', + 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'device_count', ) diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py index 78abfdd19..92f5183b7 100644 --- a/netbox/dcim/tables/modules.py +++ b/netbox/dcim/tables/modules.py @@ -56,10 +56,10 @@ class ModuleTypeTable(PrimaryModelTable): order_by=('_abs_weight', 'weight_unit') ) attributes = columns.DictColumn() - instance_count = columns.LinkedCountColumn( + module_count = columns.LinkedCountColumn( viewname='dcim:module_list', url_params={'module_type_id': 'pk'}, - verbose_name=_('Instances') + verbose_name=_('Module Count'), ) tags = columns.TagColumn( url_name='dcim:moduletype_list' @@ -69,10 +69,10 @@ class ModuleTypeTable(PrimaryModelTable): model = ModuleType fields = ( 'pk', 'id', 'model', 'profile', 'manufacturer', 'part_number', 'airflow', 'weight', 'description', - 'attributes', 'comments', 'tags', 'created', 'last_updated', + 'attributes', 'module_count', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( - 'pk', 'model', 'profile', 'manufacturer', 'part_number', + 'pk', 'model', 'profile', 'manufacturer', 'part_number', 'module_count', ) diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 1cc774f22..ad329262f 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -76,10 +76,10 @@ class RackTypeTable(PrimaryModelTable): template_code=WEIGHT, order_by=('_abs_max_weight', 'weight_unit') ) - instance_count = columns.LinkedCountColumn( + rack_count = columns.LinkedCountColumn( viewname='dcim:rack_list', url_params={'rack_type_id': 'pk'}, - verbose_name=_('Instances') + verbose_name=_('Rack Count'), ) tags = columns.TagColumn( url_name='dcim:rack_list' @@ -90,10 +90,10 @@ class RackTypeTable(PrimaryModelTable): fields = ( 'pk', 'id', 'model', 'manufacturer', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width', 'outer_height', 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'description', - 'comments', 'instance_count', 'tags', 'created', 'last_updated', + 'comments', 'rack_count', 'tags', 'created', 'last_updated', ) default_columns = ( - 'pk', 'model', 'manufacturer', 'type', 'u_height', 'description', 'instance_count', + 'pk', 'model', 'manufacturer', 'type', 'u_height', 'description', 'rack_count', ) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index c70b546e3..938a625b0 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -317,7 +317,7 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase): class RackTypeTest(APIViewTestCases.APIViewTestCase): model = RackType - brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'slug', 'url'] + brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'rack_count', 'slug', 'url'] bulk_update_data = { 'description': 'new description', } @@ -610,7 +610,7 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase): class ModuleTypeTest(APIViewTestCases.APIViewTestCase): model = ModuleType - brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'profile', 'url'] + brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'module_count', 'profile', 'url'] bulk_update_data = { 'part_number': 'ABC123', } diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 9de9bd513..463d98179 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -856,9 +856,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView): @register_model_view(RackType, 'list', path='', detail=False) class RackTypeListView(generic.ObjectListView): - queryset = RackType.objects.annotate( - instance_count=count_related(Rack, 'rack_type') - ) + queryset = RackType.objects.all() filterset = filtersets.RackTypeFilterSet filterset_form = forms.RackTypeFilterForm table = tables.RackTypeTable @@ -1298,9 +1296,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView): @register_model_view(DeviceType, 'list', path='', detail=False) class DeviceTypeListView(generic.ObjectListView): - queryset = DeviceType.objects.annotate( - instance_count=count_related(Device, 'device_type') - ) + queryset = DeviceType.objects.all() filterset = filtersets.DeviceTypeFilterSet filterset_form = forms.DeviceTypeFilterForm table = tables.DeviceTypeTable @@ -1531,9 +1527,7 @@ class DeviceTypeImportView(generic.BulkImportView): @register_model_view(DeviceType, 'bulk_edit', path='edit', detail=False) class DeviceTypeBulkEditView(generic.BulkEditView): - queryset = DeviceType.objects.annotate( - instance_count=count_related(Device, 'device_type') - ) + queryset = DeviceType.objects.all() filterset = filtersets.DeviceTypeFilterSet table = tables.DeviceTypeTable form = forms.DeviceTypeBulkEditForm @@ -1548,9 +1542,7 @@ class DeviceTypeBulkRenameView(generic.BulkRenameView): @register_model_view(DeviceType, 'bulk_delete', path='delete', detail=False) class DeviceTypeBulkDeleteView(generic.BulkDeleteView): - queryset = DeviceType.objects.annotate( - instance_count=count_related(Device, 'device_type') - ) + queryset = DeviceType.objects.all() filterset = filtersets.DeviceTypeFilterSet table = tables.DeviceTypeTable @@ -1652,9 +1644,7 @@ class ModuleTypeProfileBulkDeleteView(generic.BulkDeleteView): @register_model_view(ModuleType, 'list', path='', detail=False) class ModuleTypeListView(generic.ObjectListView): - queryset = ModuleType.objects.annotate( - instance_count=count_related(Module, 'module_type') - ) + queryset = ModuleType.objects.all() filterset = filtersets.ModuleTypeFilterSet filterset_form = forms.ModuleTypeFilterForm table = tables.ModuleTypeTable diff --git a/netbox/utilities/tests/test_counters.py b/netbox/utilities/tests/test_counters.py index 668965e8a..6948923d7 100644 --- a/netbox/utilities/tests/test_counters.py +++ b/netbox/utilities/tests/test_counters.py @@ -2,13 +2,14 @@ from django.test import override_settings from django.urls import reverse from dcim.models import * +from utilities.counters import connect_counters from utilities.testing.base import TestCase from utilities.testing.utils import create_test_device class CountersTest(TestCase): """ - Validate the operation of dict_to_filter_params(). + Validate the operation of the CounterCacheField (tracking counters). """ @classmethod def setUpTestData(cls): @@ -24,7 +25,7 @@ class CountersTest(TestCase): def test_interface_count_creation(self): """ - When a tracked object (Interface) is added the tracking counter should be updated. + When a tracked object (Interface) is added, the tracking counter should be updated. """ device1, device2 = Device.objects.all() self.assertEqual(device1.interface_count, 2) @@ -51,7 +52,7 @@ class CountersTest(TestCase): def test_interface_count_deletion(self): """ - When a tracked object (Interface) is deleted the tracking counter should be updated. + When a tracked object (Interface) is deleted, the tracking counter should be updated. """ device1, device2 = Device.objects.all() self.assertEqual(device1.interface_count, 2) @@ -66,7 +67,7 @@ class CountersTest(TestCase): def test_interface_count_move(self): """ - When a tracked object (Interface) is moved the tracking counter should be updated. + When a tracked object (Interface) is moved, the tracking counter should be updated. """ device1, device2 = Device.objects.all() self.assertEqual(device1.interface_count, 2) @@ -102,3 +103,35 @@ class CountersTest(TestCase): self.client.post(reverse("dcim:inventoryitem_bulk_delete"), data) device1.refresh_from_db() self.assertEqual(device1.inventory_item_count, 0) + + def test_signal_connections_are_idempotent_per_sender(self): + """ + Calling connect_counters() again must not register duplicate receivers. + Creating a device after repeated "connect_counters" should still yield +1. + """ + connect_counters(DeviceType, VirtualChassis) + vc, _ = VirtualChassis.objects.get_or_create(name='Virtual Chassis 1') + device1, device2 = Device.objects.all() + self.assertEqual(device1.device_type.device_count, 2) + self.assertEqual(vc.member_count, 0) + + # Call again (should be a no-op for sender registrations) + connect_counters(DeviceType, VirtualChassis) + + # Create one new device + device3 = create_test_device('Device 3') + device3.virtual_chassis = vc + device3.save() + + # Ensure counter incremented correctly + device1.refresh_from_db() + vc.refresh_from_db() + self.assertEqual(device1.device_type.device_count, 3, 'device_count should increment exactly once') + self.assertEqual(vc.member_count, 1, 'member_count should increment exactly once') + + # Clean up and ensure counter decremented correctly + device3.delete() + device1.refresh_from_db() + vc.refresh_from_db() + self.assertEqual(device1.device_type.device_count, 2, 'device_count should decrement exactly once') + self.assertEqual(vc.member_count, 0, 'member_count should decrement exactly once') From 867a01fae5e431f9a0f893b8cfbb3fc70f089bb0 Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Thu, 23 Oct 2025 20:19:16 -0400 Subject: [PATCH 082/125] Clone all GraphQL objects to V1 versions --- netbox/circuits/graphql/filter_mixins_v1.py | 19 + netbox/circuits/graphql/filters_v1.py | 228 ++++ netbox/circuits/graphql/schema_v1.py | 42 + netbox/circuits/graphql/types_v1.py | 189 +++ netbox/core/graphql/filter_mixins_v1.py | 36 + netbox/core/graphql/filters_v1.py | 89 ++ netbox/core/graphql/mixins_v1.py | 35 + netbox/core/graphql/schema_v1.py | 15 + netbox/core/graphql/types_v1.py | 56 + netbox/dcim/graphql/filter_mixins_v1.py | 155 +++ netbox/dcim/graphql/filters_v1.py | 1020 +++++++++++++++++ netbox/dcim/graphql/gfk_mixins_v1.py | 137 +++ netbox/dcim/graphql/mixins_v1.py | 43 + netbox/dcim/graphql/schema_v1.py | 138 +++ netbox/dcim/graphql/types_v1.py | 906 +++++++++++++++ netbox/extras/graphql/filter_mixins_v1.py | 52 + netbox/extras/graphql/filters_v1.py | 357 ++++++ netbox/extras/graphql/mixins_v1.py | 62 + netbox/extras/graphql/schema_v1.py | 60 + netbox/extras/graphql/types_v1.py | 239 ++++ netbox/ipam/graphql/filter_mixins_v1.py | 25 + netbox/ipam/graphql/filters_v1.py | 392 +++++++ netbox/ipam/graphql/mixins_v1.py | 18 + netbox/ipam/graphql/schema_v1.py | 63 + netbox/ipam/graphql/types_v1.py | 360 ++++++ netbox/netbox/graphql/filter_mixins_v1.py | 104 ++ netbox/netbox/graphql/schema.py | 30 +- netbox/netbox/graphql/types_v1.py | 100 ++ netbox/tenancy/graphql/filter_mixins_v1.py | 38 + netbox/tenancy/graphql/filters_v1.py | 210 ++++ netbox/tenancy/graphql/mixins_v1.py | 12 + netbox/tenancy/graphql/schema_v1.py | 27 + netbox/tenancy/graphql/types_v1.py | 147 +++ netbox/users/graphql/filters_v1.py | 34 + netbox/users/graphql/schema_v1.py | 15 + netbox/users/graphql/types_v1.py | 34 + .../graphql/filter_mixins_v1.py | 28 + netbox/virtualization/graphql/filters_v1.py | 170 +++ netbox/virtualization/graphql/schema_v1.py | 27 + netbox/virtualization/graphql/types_v1.py | 146 +++ netbox/vpn/graphql/filters_v1.py | 192 ++++ netbox/vpn/graphql/schema_v1.py | 39 + netbox/vpn/graphql/types_v1.py | 157 +++ netbox/wireless/graphql/filter_mixins_v1.py | 26 + netbox/wireless/graphql/filters_v1.py | 72 ++ netbox/wireless/graphql/schema_v1.py | 18 + netbox/wireless/graphql/types_v1.py | 71 ++ 47 files changed, 6423 insertions(+), 10 deletions(-) create mode 100644 netbox/circuits/graphql/filter_mixins_v1.py create mode 100644 netbox/circuits/graphql/filters_v1.py create mode 100644 netbox/circuits/graphql/schema_v1.py create mode 100644 netbox/circuits/graphql/types_v1.py create mode 100644 netbox/core/graphql/filter_mixins_v1.py create mode 100644 netbox/core/graphql/filters_v1.py create mode 100644 netbox/core/graphql/mixins_v1.py create mode 100644 netbox/core/graphql/schema_v1.py create mode 100644 netbox/core/graphql/types_v1.py create mode 100644 netbox/dcim/graphql/filter_mixins_v1.py create mode 100644 netbox/dcim/graphql/filters_v1.py create mode 100644 netbox/dcim/graphql/gfk_mixins_v1.py create mode 100644 netbox/dcim/graphql/mixins_v1.py create mode 100644 netbox/dcim/graphql/schema_v1.py create mode 100644 netbox/dcim/graphql/types_v1.py create mode 100644 netbox/extras/graphql/filter_mixins_v1.py create mode 100644 netbox/extras/graphql/filters_v1.py create mode 100644 netbox/extras/graphql/mixins_v1.py create mode 100644 netbox/extras/graphql/schema_v1.py create mode 100644 netbox/extras/graphql/types_v1.py create mode 100644 netbox/ipam/graphql/filter_mixins_v1.py create mode 100644 netbox/ipam/graphql/filters_v1.py create mode 100644 netbox/ipam/graphql/mixins_v1.py create mode 100644 netbox/ipam/graphql/schema_v1.py create mode 100644 netbox/ipam/graphql/types_v1.py create mode 100644 netbox/netbox/graphql/filter_mixins_v1.py create mode 100644 netbox/netbox/graphql/types_v1.py create mode 100644 netbox/tenancy/graphql/filter_mixins_v1.py create mode 100644 netbox/tenancy/graphql/filters_v1.py create mode 100644 netbox/tenancy/graphql/mixins_v1.py create mode 100644 netbox/tenancy/graphql/schema_v1.py create mode 100644 netbox/tenancy/graphql/types_v1.py create mode 100644 netbox/users/graphql/filters_v1.py create mode 100644 netbox/users/graphql/schema_v1.py create mode 100644 netbox/users/graphql/types_v1.py create mode 100644 netbox/virtualization/graphql/filter_mixins_v1.py create mode 100644 netbox/virtualization/graphql/filters_v1.py create mode 100644 netbox/virtualization/graphql/schema_v1.py create mode 100644 netbox/virtualization/graphql/types_v1.py create mode 100644 netbox/vpn/graphql/filters_v1.py create mode 100644 netbox/vpn/graphql/schema_v1.py create mode 100644 netbox/vpn/graphql/types_v1.py create mode 100644 netbox/wireless/graphql/filter_mixins_v1.py create mode 100644 netbox/wireless/graphql/filters_v1.py create mode 100644 netbox/wireless/graphql/schema_v1.py create mode 100644 netbox/wireless/graphql/types_v1.py diff --git a/netbox/circuits/graphql/filter_mixins_v1.py b/netbox/circuits/graphql/filter_mixins_v1.py new file mode 100644 index 000000000..15ebce2d4 --- /dev/null +++ b/netbox/circuits/graphql/filter_mixins_v1.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django + +from netbox.graphql.filter_mixins_v1 import OrganizationalModelFilterMixinV1 + +if TYPE_CHECKING: + from netbox.graphql.enums import ColorEnum + +__all__ = ( + 'BaseCircuitTypeFilterMixinV1', +) + + +@dataclass +class BaseCircuitTypeFilterMixinV1(OrganizationalModelFilterMixinV1): + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() diff --git a/netbox/circuits/graphql/filters_v1.py b/netbox/circuits/graphql/filters_v1.py new file mode 100644 index 000000000..caf4b7c62 --- /dev/null +++ b/netbox/circuits/graphql/filters_v1.py @@ -0,0 +1,228 @@ +from datetime import date +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django +from strawberry.scalars import ID +from strawberry_django import FilterLookup, DateFilterLookup + +from circuits import models +from core.graphql.filter_mixins_v1 import BaseObjectTypeFilterMixinV1, ChangeLogFilterMixinV1 +from dcim.graphql.filter_mixins_v1 import CabledObjectModelFilterMixinV1 +from extras.graphql.filter_mixins_v1 import CustomFieldsFilterMixinV1, TagsFilterMixinV1 +from netbox.graphql.filter_mixins_v1 import ( + DistanceFilterMixinV1, + ImageAttachmentFilterMixinV1, + OrganizationalModelFilterMixinV1, + PrimaryModelFilterMixinV1, +) +from tenancy.graphql.filter_mixins_v1 import ContactFilterMixinV1, TenancyFilterMixinV1 +from .filter_mixins_v1 import BaseCircuitTypeFilterMixinV1 + +if TYPE_CHECKING: + from core.graphql.filters_v1 import ContentTypeFilterV1 + from dcim.graphql.filters_v1 import ( + InterfaceFilterV1, LocationFilterV1, RegionFilterV1, SiteFilterV1, SiteGroupFilterV1 + ) + from ipam.graphql.filters_v1 import ASNFilterV1 + from netbox.graphql.filter_lookups import IntegerLookup + from .enums import * + +__all__ = ( + 'CircuitFilterV1', + 'CircuitGroupAssignmentFilterV1', + 'CircuitGroupFilterV1', + 'CircuitTerminationFilterV1', + 'CircuitTypeFilterV1', + 'ProviderFilterV1', + 'ProviderAccountFilterV1', + 'ProviderNetworkFilterV1', + 'VirtualCircuitFilterV1', + 'VirtualCircuitTerminationFilterV1', + 'VirtualCircuitTypeFilterV1', +) + + +@strawberry_django.filter_type(models.CircuitTermination, lookups=True) +class CircuitTerminationFilterV1( + BaseObjectTypeFilterMixinV1, + CustomFieldsFilterMixinV1, + TagsFilterMixinV1, + ChangeLogFilterMixinV1, + CabledObjectModelFilterMixinV1, +): + circuit: Annotated['CircuitFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + term_side: Annotated['CircuitTerminationSideEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + termination_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + termination_id: ID | None = strawberry_django.filter_field() + port_speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + upstream_speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + xconnect_id: FilterLookup[str] | None = strawberry_django.filter_field() + pp_info: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + + # Cached relations + _provider_network: Annotated['ProviderNetworkFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( + strawberry_django.filter_field(name='provider_network') + ) + _location: Annotated['LocationFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field(name='location') + ) + _region: Annotated['RegionFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field(name='region') + ) + _site_group: Annotated['SiteGroupFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field(name='site_group') + ) + _site: Annotated['SiteFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field(name='site') + ) + + +@strawberry_django.filter_type(models.Circuit, lookups=True) +class CircuitFilterV1( + ContactFilterMixinV1, + ImageAttachmentFilterMixinV1, + DistanceFilterMixinV1, + TenancyFilterMixinV1, + PrimaryModelFilterMixinV1 +): + cid: FilterLookup[str] | None = strawberry_django.filter_field() + provider: Annotated['ProviderFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + provider_id: ID | None = strawberry_django.filter_field() + provider_account: Annotated['ProviderAccountFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + provider_account_id: ID | None = strawberry_django.filter_field() + type: Annotated['CircuitTypeFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + type_id: ID | None = strawberry_django.filter_field() + status: Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + install_date: DateFilterLookup[date] | None = strawberry_django.filter_field() + termination_date: DateFilterLookup[date] | None = strawberry_django.filter_field() + commit_rate: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + terminations: Annotated['CircuitTerminationFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.CircuitType, lookups=True) +class CircuitTypeFilterV1(BaseCircuitTypeFilterMixinV1): + pass + + +@strawberry_django.filter_type(models.CircuitGroup, lookups=True) +class CircuitGroupFilterV1(TenancyFilterMixinV1, OrganizationalModelFilterMixinV1): + pass + + +@strawberry_django.filter_type(models.CircuitGroupAssignment, lookups=True) +class CircuitGroupAssignmentFilterV1( + BaseObjectTypeFilterMixinV1, CustomFieldsFilterMixinV1, TagsFilterMixinV1, ChangeLogFilterMixinV1 +): + member_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + member_id: ID | None = strawberry_django.filter_field() + group: Annotated['CircuitGroupFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + group_id: ID | None = strawberry_django.filter_field() + priority: Annotated['CircuitPriorityEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.Provider, lookups=True) +class ProviderFilterV1(ContactFilterMixinV1, PrimaryModelFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() + asns: Annotated['ASNFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = strawberry_django.filter_field() + circuits: Annotated['CircuitFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.ProviderAccount, lookups=True) +class ProviderAccountFilterV1(ContactFilterMixinV1, PrimaryModelFilterMixinV1): + provider: Annotated['ProviderFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + provider_id: ID | None = strawberry_django.filter_field() + account: FilterLookup[str] | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.ProviderNetwork, lookups=True) +class ProviderNetworkFilterV1(PrimaryModelFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + provider: Annotated['ProviderFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + provider_id: ID | None = strawberry_django.filter_field() + service_id: FilterLookup[str] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.VirtualCircuitType, lookups=True) +class VirtualCircuitTypeFilterV1(BaseCircuitTypeFilterMixinV1): + pass + + +@strawberry_django.filter_type(models.VirtualCircuit, lookups=True) +class VirtualCircuitFilterV1(TenancyFilterMixinV1, PrimaryModelFilterMixinV1): + cid: FilterLookup[str] | None = strawberry_django.filter_field() + provider_network: Annotated['ProviderNetworkFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + provider_network_id: ID | None = strawberry_django.filter_field() + provider_account: Annotated['ProviderAccountFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + provider_account_id: ID | None = strawberry_django.filter_field() + type: Annotated['VirtualCircuitTypeFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + type_id: ID | None = strawberry_django.filter_field() + status: Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + group_assignments: Annotated[ + 'CircuitGroupAssignmentFilterV1', strawberry.lazy('circuits.graphql.filters_v1') + ] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.VirtualCircuitTermination, lookups=True) +class VirtualCircuitTerminationFilterV1( + BaseObjectTypeFilterMixinV1, CustomFieldsFilterMixinV1, TagsFilterMixinV1, ChangeLogFilterMixinV1 +): + virtual_circuit: Annotated['VirtualCircuitFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + virtual_circuit_id: ID | None = strawberry_django.filter_field() + role: Annotated['VirtualCircuitTerminationRoleEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + interface: Annotated['InterfaceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + interface_id: ID | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/circuits/graphql/schema_v1.py b/netbox/circuits/graphql/schema_v1.py new file mode 100644 index 000000000..1134978f9 --- /dev/null +++ b/netbox/circuits/graphql/schema_v1.py @@ -0,0 +1,42 @@ +from typing import List + +import strawberry +import strawberry_django + +from .types_v1 import * + + +@strawberry.type(name="Query") +class CircuitsQueryV1: + circuit: CircuitTypeV1 = strawberry_django.field() + circuit_list: List[CircuitTypeV1] = strawberry_django.field() + + circuit_termination: CircuitTerminationTypeV1 = strawberry_django.field() + circuit_termination_list: List[CircuitTerminationTypeV1] = strawberry_django.field() + + circuit_type: CircuitTypeTypeV1 = strawberry_django.field() + circuit_type_list: List[CircuitTypeTypeV1] = strawberry_django.field() + + circuit_group: CircuitGroupTypeV1 = strawberry_django.field() + circuit_group_list: List[CircuitGroupTypeV1] = strawberry_django.field() + + circuit_group_assignment: CircuitGroupAssignmentTypeV1 = strawberry_django.field() + circuit_group_assignment_list: List[CircuitGroupAssignmentTypeV1] = strawberry_django.field() + + provider: ProviderTypeV1 = strawberry_django.field() + provider_list: List[ProviderTypeV1] = strawberry_django.field() + + provider_account: ProviderAccountTypeV1 = strawberry_django.field() + provider_account_list: List[ProviderAccountTypeV1] = strawberry_django.field() + + provider_network: ProviderNetworkTypeV1 = strawberry_django.field() + provider_network_list: List[ProviderNetworkTypeV1] = strawberry_django.field() + + virtual_circuit: VirtualCircuitTypeV1 = strawberry_django.field() + virtual_circuit_list: List[VirtualCircuitTypeV1] = strawberry_django.field() + + virtual_circuit_termination: VirtualCircuitTerminationTypeV1 = strawberry_django.field() + virtual_circuit_termination_list: List[VirtualCircuitTerminationTypeV1] = strawberry_django.field() + + virtual_circuit_type: VirtualCircuitTypeTypeV1 = strawberry_django.field() + virtual_circuit_type_list: List[VirtualCircuitTypeTypeV1] = strawberry_django.field() diff --git a/netbox/circuits/graphql/types_v1.py b/netbox/circuits/graphql/types_v1.py new file mode 100644 index 000000000..3e7669df6 --- /dev/null +++ b/netbox/circuits/graphql/types_v1.py @@ -0,0 +1,189 @@ +from typing import Annotated, List, TYPE_CHECKING, Union + +import strawberry +import strawberry_django + +from circuits import models +from dcim.graphql.mixins_v1 import CabledObjectMixinV1 +from extras.graphql.mixins_v1 import ContactsMixinV1, CustomFieldsMixinV1, TagsMixinV1 +from netbox.graphql.types_v1 import BaseObjectTypeV1, NetBoxObjectTypeV1, ObjectTypeV1, OrganizationalObjectTypeV1 +from tenancy.graphql.types_v1 import TenantTypeV1 +from .filters_v1 import * + +if TYPE_CHECKING: + from dcim.graphql.types_v1 import InterfaceTypeV1, LocationTypeV1, RegionTypeV1, SiteGroupTypeV1, SiteTypeV1 + from ipam.graphql.types_v1 import ASNTypeV1 + +__all__ = ( + 'CircuitGroupAssignmentTypeV1', + 'CircuitGroupTypeV1', + 'CircuitTerminationTypeV1', + 'CircuitTypeV1', + 'CircuitTypeTypeV1', + 'ProviderTypeV1', + 'ProviderAccountTypeV1', + 'ProviderNetworkTypeV1', + 'VirtualCircuitTerminationTypeV1', + 'VirtualCircuitTypeV1', + 'VirtualCircuitTypeTypeV1', +) + + +@strawberry_django.type( + models.Provider, + fields='__all__', + filters=ProviderFilterV1, + pagination=True +) +class ProviderTypeV1(NetBoxObjectTypeV1, ContactsMixinV1): + + networks: List[Annotated["ProviderNetworkTypeV1", strawberry.lazy('circuits.graphql.types_v1')]] + circuits: List[Annotated["CircuitTypeV1", strawberry.lazy('circuits.graphql.types_v1')]] + asns: List[Annotated["ASNTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] + accounts: List[Annotated["ProviderAccountTypeV1", strawberry.lazy('circuits.graphql.types_v1')]] + + +@strawberry_django.type( + models.ProviderAccount, + fields='__all__', + filters=ProviderAccountFilterV1, + pagination=True +) +class ProviderAccountTypeV1(ContactsMixinV1, NetBoxObjectTypeV1): + provider: Annotated["ProviderTypeV1", strawberry.lazy('circuits.graphql.types_v1')] + + circuits: List[Annotated["CircuitTypeV1", strawberry.lazy('circuits.graphql.types_v1')]] + + +@strawberry_django.type( + models.ProviderNetwork, + fields='__all__', + filters=ProviderNetworkFilterV1, + pagination=True +) +class ProviderNetworkTypeV1(NetBoxObjectTypeV1): + provider: Annotated["ProviderTypeV1", strawberry.lazy('circuits.graphql.types_v1')] + + circuit_terminations: List[Annotated["CircuitTerminationTypeV1", strawberry.lazy('circuits.graphql.types_v1')]] + + +@strawberry_django.type( + models.CircuitTermination, + exclude=['termination_type', 'termination_id', '_location', '_region', '_site', '_site_group', '_provider_network'], + filters=CircuitTerminationFilterV1, + pagination=True +) +class CircuitTerminationTypeV1(CustomFieldsMixinV1, TagsMixinV1, CabledObjectMixinV1, ObjectTypeV1): + circuit: Annotated["CircuitTypeV1", strawberry.lazy('circuits.graphql.types_v1')] + + @strawberry_django.field + def termination(self) -> Annotated[Union[ + Annotated["LocationTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["RegionTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["SiteGroupTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["SiteTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["ProviderNetworkTypeV1", strawberry.lazy('circuits.graphql.types_v1')], + ], strawberry.union("CircuitTerminationTerminationTypeV1")] | None: + return self.termination + + +@strawberry_django.type( + models.CircuitType, + fields='__all__', + filters=CircuitTypeFilterV1, + pagination=True +) +class CircuitTypeTypeV1(OrganizationalObjectTypeV1): + color: str + + circuits: List[Annotated["CircuitTypeV1", strawberry.lazy('circuits.graphql.types_v1')]] + + +@strawberry_django.type( + models.Circuit, + fields='__all__', + filters=CircuitFilterV1, + pagination=True +) +class CircuitTypeV1(NetBoxObjectTypeV1, ContactsMixinV1): + provider: ProviderTypeV1 + provider_account: ProviderAccountTypeV1 | None + termination_a: CircuitTerminationTypeV1 | None + termination_z: CircuitTerminationTypeV1 | None + type: CircuitTypeTypeV1 + tenant: TenantTypeV1 | None + + terminations: List[CircuitTerminationTypeV1] + + +@strawberry_django.type( + models.CircuitGroup, + fields='__all__', + filters=CircuitGroupFilterV1, + pagination=True +) +class CircuitGroupTypeV1(OrganizationalObjectTypeV1): + tenant: TenantTypeV1 | None + + +@strawberry_django.type( + models.CircuitGroupAssignment, + exclude=['member_type', 'member_id'], + filters=CircuitGroupAssignmentFilterV1, + pagination=True +) +class CircuitGroupAssignmentTypeV1(TagsMixinV1, BaseObjectTypeV1): + group: Annotated["CircuitGroupTypeV1", strawberry.lazy('circuits.graphql.types_v1')] + + @strawberry_django.field + def member(self) -> Annotated[Union[ + Annotated["CircuitTypeV1", strawberry.lazy('circuits.graphql.types_v1')], + Annotated["VirtualCircuitTypeV1", strawberry.lazy('circuits.graphql.types_v1')], + ], strawberry.union("CircuitGroupAssignmentMemberTypeV1")] | None: + return self.member + + +@strawberry_django.type( + models.VirtualCircuitType, + fields='__all__', + filters=VirtualCircuitTypeFilterV1, + pagination=True +) +class VirtualCircuitTypeTypeV1(OrganizationalObjectTypeV1): + color: str + + virtual_circuits: List[Annotated["VirtualCircuitTypeV1", strawberry.lazy('circuits.graphql.types_v1')]] + + +@strawberry_django.type( + models.VirtualCircuitTermination, + fields='__all__', + filters=VirtualCircuitTerminationFilterV1, + pagination=True +) +class VirtualCircuitTerminationTypeV1(CustomFieldsMixinV1, TagsMixinV1, ObjectTypeV1): + virtual_circuit: Annotated[ + "VirtualCircuitTypeV1", + strawberry.lazy('circuits.graphql.types_v1') + ] = strawberry_django.field(select_related=["virtual_circuit"]) + interface: Annotated[ + "InterfaceTypeV1", + strawberry.lazy('dcim.graphql.types_v1') + ] = strawberry_django.field(select_related=["interface"]) + + +@strawberry_django.type( + models.VirtualCircuit, + fields='__all__', + filters=VirtualCircuitFilterV1, + pagination=True +) +class VirtualCircuitTypeV1(NetBoxObjectTypeV1): + provider_network: ProviderNetworkTypeV1 = strawberry_django.field(select_related=["provider_network"]) + provider_account: ProviderAccountTypeV1 | None + type: Annotated["VirtualCircuitTypeTypeV1", strawberry.lazy('circuits.graphql.types_v1')] = strawberry_django.field( + select_related=["type"] + ) + tenant: TenantTypeV1 | None + + terminations: List[VirtualCircuitTerminationTypeV1] diff --git a/netbox/core/graphql/filter_mixins_v1.py b/netbox/core/graphql/filter_mixins_v1.py new file mode 100644 index 000000000..58c39705f --- /dev/null +++ b/netbox/core/graphql/filter_mixins_v1.py @@ -0,0 +1,36 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django +from strawberry import ID +from strawberry_django import DatetimeFilterLookup + +if TYPE_CHECKING: + from .filters_v1 import * + +__all__ = ( + 'BaseFilterMixinV1', + 'BaseObjectTypeFilterMixinV1', + 'ChangeLogFilterMixinV1', +) + + +# @strawberry.input +class BaseFilterMixinV1: ... + + +@dataclass +class BaseObjectTypeFilterMixinV1(BaseFilterMixinV1): + id: ID | None = strawberry.UNSET + + +@dataclass +class ChangeLogFilterMixinV1(BaseFilterMixinV1): + id: ID | None = strawberry.UNSET + changelog: Annotated['ObjectChangeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() + last_updated: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() diff --git a/netbox/core/graphql/filters_v1.py b/netbox/core/graphql/filters_v1.py new file mode 100644 index 000000000..c75de0c75 --- /dev/null +++ b/netbox/core/graphql/filters_v1.py @@ -0,0 +1,89 @@ +from datetime import datetime +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django +from django.contrib.contenttypes.models import ContentType as DjangoContentType +from strawberry.scalars import ID +from strawberry_django import DatetimeFilterLookup, FilterLookup + +from core import models +from core.graphql.filter_mixins_v1 import BaseFilterMixinV1 +from netbox.graphql.filter_mixins import PrimaryModelFilterMixin + +if TYPE_CHECKING: + from netbox.graphql.filter_lookups import IntegerLookup, JSONFilter + from users.graphql.filters import UserFilter + +__all__ = ( + 'DataFileFilterV1', + 'DataSourceFilterV1', + 'ObjectChangeFilterV1', + 'ContentTypeFilterV1', +) + + +@strawberry_django.filter_type(models.DataFile, lookups=True) +class DataFileFilterV1(BaseFilterMixinV1): + id: ID | None = strawberry_django.filter_field() + created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() + last_updated: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() + source: Annotated['DataSourceFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + source_id: ID | None = strawberry_django.filter_field() + path: FilterLookup[str] | None = strawberry_django.filter_field() + size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + hash: FilterLookup[str] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.DataSource, lookups=True) +class DataSourceFilterV1(PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + type: FilterLookup[str] | None = strawberry_django.filter_field() + source_url: FilterLookup[str] | None = strawberry_django.filter_field() + status: FilterLookup[str] | None = strawberry_django.filter_field() + enabled: FilterLookup[bool] | None = strawberry_django.filter_field() + ignore_rules: FilterLookup[str] | None = strawberry_django.filter_field() + parameters: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + last_synced: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() + datafiles: Annotated['DataFileFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.ObjectChange, lookups=True) +class ObjectChangeFilterV1(BaseFilterMixinV1): + id: ID | None = strawberry_django.filter_field() + time: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() + user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() + user_name: FilterLookup[str] | None = strawberry_django.filter_field() + request_id: FilterLookup[str] | None = strawberry_django.filter_field() + action: FilterLookup[str] | None = strawberry_django.filter_field() + changed_object_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + changed_object_type_id: ID | None = strawberry_django.filter_field() + changed_object_id: ID | None = strawberry_django.filter_field() + related_object_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + related_object_id: ID | None = strawberry_django.filter_field() + object_repr: FilterLookup[str] | None = strawberry_django.filter_field() + prechange_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + postchange_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(DjangoContentType, lookups=True) +class ContentTypeFilterV1(BaseFilterMixinV1): + id: ID | None = strawberry_django.filter_field() + app_label: FilterLookup[str] | None = strawberry_django.filter_field() + model: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/core/graphql/mixins_v1.py b/netbox/core/graphql/mixins_v1.py new file mode 100644 index 000000000..722068cdd --- /dev/null +++ b/netbox/core/graphql/mixins_v1.py @@ -0,0 +1,35 @@ +from typing import Annotated, List, TYPE_CHECKING + +import strawberry +import strawberry_django +from django.contrib.contenttypes.models import ContentType +from strawberry.types import Info + +from core.models import ObjectChange + +if TYPE_CHECKING: + from core.graphql.types_v1 import DataFileTypeV1, DataSourceTypeV1, ObjectChangeTypeV1 + +__all__ = ( + 'ChangelogMixinV1', + 'SyncedDataMixinV1', +) + + +@strawberry.type +class ChangelogMixinV1: + + @strawberry_django.field + def changelog(self, info: Info) -> List[Annotated['ObjectChangeTypeV1', strawberry.lazy('.types_v1')]]: # noqa: F821 + content_type = ContentType.objects.get_for_model(self) + object_changes = ObjectChange.objects.filter( + changed_object_type=content_type, + changed_object_id=self.pk + ) + return object_changes.restrict(info.context.request.user, 'view') + + +@strawberry.type +class SyncedDataMixinV1: + data_source: Annotated['DataSourceTypeV1', strawberry.lazy('core.graphql.types_v1')] | None + data_file: Annotated['DataFileTypeV1', strawberry.lazy('core.graphql.types_v1')] | None diff --git a/netbox/core/graphql/schema_v1.py b/netbox/core/graphql/schema_v1.py new file mode 100644 index 000000000..b94d14a70 --- /dev/null +++ b/netbox/core/graphql/schema_v1.py @@ -0,0 +1,15 @@ +from typing import List + +import strawberry +import strawberry_django + +from .types_v1 import * + + +@strawberry.type(name="Query") +class CoreQueryV1: + data_file: DataFileTypeV1 = strawberry_django.field() + data_file_list: List[DataFileTypeV1] = strawberry_django.field() + + data_source: DataSourceTypeV1 = strawberry_django.field() + data_source_list: List[DataSourceTypeV1] = strawberry_django.field() diff --git a/netbox/core/graphql/types_v1.py b/netbox/core/graphql/types_v1.py new file mode 100644 index 000000000..67effeae4 --- /dev/null +++ b/netbox/core/graphql/types_v1.py @@ -0,0 +1,56 @@ +from typing import Annotated, List + +import strawberry +import strawberry_django +from django.contrib.contenttypes.models import ContentType as DjangoContentType + +from core import models +from netbox.graphql.types_v1 import BaseObjectTypeV1, NetBoxObjectTypeV1 +from .filters_v1 import * + +__all__ = ( + 'ContentTypeV1', + 'DataFileTypeV1', + 'DataSourceTypeV1', + 'ObjectChangeTypeV1', +) + + +@strawberry_django.type( + models.DataFile, + exclude=['data',], + filters=DataFileFilterV1, + pagination=True +) +class DataFileTypeV1(BaseObjectTypeV1): + source: Annotated["DataSourceTypeV1", strawberry.lazy('core.graphql.types_v1')] + + +@strawberry_django.type( + models.DataSource, + fields='__all__', + filters=DataSourceFilterV1, + pagination=True +) +class DataSourceTypeV1(NetBoxObjectTypeV1): + + datafiles: List[Annotated["DataFileTypeV1", strawberry.lazy('core.graphql.types_v1')]] + + +@strawberry_django.type( + models.ObjectChange, + fields='__all__', + filters=ObjectChangeFilterV1, + pagination=True +) +class ObjectChangeTypeV1(BaseObjectTypeV1): + pass + + +@strawberry_django.type( + DjangoContentType, + fields='__all__', + pagination=True +) +class ContentTypeV1: + pass diff --git a/netbox/dcim/graphql/filter_mixins_v1.py b/netbox/dcim/graphql/filter_mixins_v1.py new file mode 100644 index 000000000..55f329497 --- /dev/null +++ b/netbox/dcim/graphql/filter_mixins_v1.py @@ -0,0 +1,155 @@ +from dataclasses import dataclass +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django +from strawberry import ID +from strawberry_django import FilterLookup + +from core.graphql.filter_mixins_v1 import BaseFilterMixinV1, ChangeLogFilterMixinV1 +from core.graphql.filters_v1 import ContentTypeFilterV1 +from netbox.graphql.filter_mixins_v1 import NetBoxModelFilterMixinV1, PrimaryModelFilterMixinV1, WeightFilterMixinV1 +from .enums import * + +if TYPE_CHECKING: + from netbox.graphql.filter_lookups import IntegerLookup + from extras.graphql.filters_v1 import ConfigTemplateFilterV1 + from ipam.graphql.filters_v1 import VLANFilterV1, VLANTranslationPolicyFilterV1 + from .filters_v1 import * + +__all__ = ( + 'CabledObjectModelFilterMixinV1', + 'ComponentModelFilterMixinV1', + 'ComponentTemplateFilterMixinV1', + 'InterfaceBaseFilterMixinV1', + 'ModularComponentModelFilterMixinV1', + 'ModularComponentTemplateFilterMixinV1', + 'RackBaseFilterMixinV1', + 'RenderConfigFilterMixinV1', + 'ScopedFilterMixinV1', +) + + +@dataclass +class ScopedFilterMixinV1(BaseFilterMixinV1): + scope_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + scope_id: ID | None = strawberry_django.filter_field() + + +@dataclass +class ComponentModelFilterMixinV1(NetBoxModelFilterMixinV1): + device: Annotated['DeviceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + device_id: ID | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() + label: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + + +@dataclass +class ModularComponentModelFilterMixinV1(ComponentModelFilterMixinV1): + module: Annotated['ModuleFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + module_id: ID | None = strawberry_django.filter_field() + inventory_items: Annotated['InventoryItemFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@dataclass +class CabledObjectModelFilterMixinV1(BaseFilterMixinV1): + cable: Annotated['CableFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + cable_id: ID | None = strawberry_django.filter_field() + cable_end: CableEndEnum | None = strawberry_django.filter_field() + mark_connected: FilterLookup[bool] | None = strawberry_django.filter_field() + + +@dataclass +class ComponentTemplateFilterMixinV1(ChangeLogFilterMixinV1): + device_type: Annotated['DeviceTypeFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + device_type_id: ID | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() + label: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + + +@dataclass +class ModularComponentTemplateFilterMixinV1(ComponentTemplateFilterMixinV1): + module_type: Annotated['ModuleTypeFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@dataclass +class RenderConfigFilterMixinV1(BaseFilterMixinV1): + config_template: Annotated['ConfigTemplateFilterV1', strawberry.lazy('extras.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + config_template_id: ID | None = strawberry_django.filter_field() + + +@dataclass +class InterfaceBaseFilterMixinV1(BaseFilterMixinV1): + enabled: FilterLookup[bool] | None = strawberry_django.filter_field() + mtu: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + mode: InterfaceModeEnum | None = strawberry_django.filter_field() + bridge: Annotated['InterfaceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + bridge_id: ID | None = strawberry_django.filter_field() + untagged_vlan: Annotated['VLANFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + tagged_vlans: Annotated['VLANFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + qinq_svlan: Annotated['VLANFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + vlan_translation_policy: Annotated[ + 'VLANTranslationPolicyFilterV1', strawberry.lazy('ipam.graphql.filters_v1') + ] | None = strawberry_django.filter_field() + primary_mac_address: Annotated['MACAddressFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + primary_mac_address_id: ID | None = strawberry_django.filter_field() + + +@dataclass +class RackBaseFilterMixinV1(WeightFilterMixinV1, PrimaryModelFilterMixinV1): + width: Annotated['RackWidthEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + u_height: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + starting_unit: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + desc_units: FilterLookup[bool] | None = strawberry_django.filter_field() + outer_width: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + outer_height: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + outer_depth: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + outer_unit: Annotated['RackDimensionUnitEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + mounting_depth: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + max_weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) diff --git a/netbox/dcim/graphql/filters_v1.py b/netbox/dcim/graphql/filters_v1.py new file mode 100644 index 000000000..be87c3618 --- /dev/null +++ b/netbox/dcim/graphql/filters_v1.py @@ -0,0 +1,1020 @@ +from typing import Annotated, TYPE_CHECKING + +from django.db.models import Q +import strawberry +import strawberry_django +from strawberry.scalars import ID +from strawberry_django import FilterLookup + +from core.graphql.filter_mixins_v1 import ChangeLogFilterMixinV1 +from dcim import models +from dcim.constants import * +from dcim.graphql.enums import InterfaceKindEnum +from extras.graphql.filter_mixins_v1 import ConfigContextFilterMixinV1 +from netbox.graphql.filter_mixins_v1 import ( + PrimaryModelFilterMixinV1, + OrganizationalModelFilterMixinV1, + NestedGroupModelFilterMixinV1, + ImageAttachmentFilterMixinV1, + WeightFilterMixinV1, +) +from tenancy.graphql.filter_mixins_v1 import TenancyFilterMixinV1, ContactFilterMixinV1 +from .filter_mixins_v1 import ( + CabledObjectModelFilterMixinV1, + ComponentModelFilterMixinV1, + ComponentTemplateFilterMixinV1, + InterfaceBaseFilterMixinV1, + ModularComponentModelFilterMixinV1, + ModularComponentTemplateFilterMixinV1, + RackBaseFilterMixinV1, + RenderConfigFilterMixinV1, +) + +if TYPE_CHECKING: + from core.graphql.filters_v1 import ContentTypeFilterV1 + from extras.graphql.filters_v1 import ConfigTemplateFilterV1, ImageAttachmentFilterV1 + from ipam.graphql.filters_v1 import ( + ASNFilterV1, FHRPGroupAssignmentFilterV1, IPAddressFilterV1, PrefixFilterV1, VLANGroupFilterV1, VRFFilterV1, + ) + from netbox.graphql.enums import ColorEnum + from netbox.graphql.filter_lookups import FloatLookup, IntegerArrayLookup, IntegerLookup, TreeNodeFilter + from users.graphql.filters_v1 import UserFilterV1 + from virtualization.graphql.filters_v1 import ClusterFilterV1 + from vpn.graphql.filters_v1 import L2VPNFilterV1, TunnelTerminationFilterV1 + from wireless.graphql.enums import WirelessChannelEnum, WirelessRoleEnum + from wireless.graphql.filters_v1 import WirelessLANFilterV1, WirelessLinkFilterV1 + from .enums import * + +__all__ = ( + 'CableFilterV1', + 'CableTerminationFilterV1', + 'ConsolePortFilterV1', + 'ConsolePortTemplateFilterV1', + 'ConsoleServerPortFilterV1', + 'ConsoleServerPortTemplateFilterV1', + 'DeviceFilterV1', + 'DeviceBayFilterV1', + 'DeviceBayTemplateFilterV1', + 'DeviceRoleFilterV1', + 'DeviceTypeFilterV1', + 'FrontPortFilterV1', + 'FrontPortTemplateFilterV1', + 'InterfaceFilterV1', + 'InterfaceTemplateFilterV1', + 'InventoryItemFilterV1', + 'InventoryItemRoleFilterV1', + 'InventoryItemTemplateFilterV1', + 'LocationFilterV1', + 'MACAddressFilterV1', + 'ManufacturerFilterV1', + 'ModuleFilterV1', + 'ModuleBayFilterV1', + 'ModuleBayTemplateFilterV1', + 'ModuleTypeFilterV1', + 'ModuleTypeProfileFilterV1', + 'PlatformFilterV1', + 'PowerFeedFilterV1', + 'PowerOutletFilterV1', + 'PowerOutletTemplateFilterV1', + 'PowerPanelFilterV1', + 'PowerPortFilterV1', + 'PowerPortTemplateFilterV1', + 'RackFilterV1', + 'RackReservationFilterV1', + 'RackRoleFilterV1', + 'RackTypeFilterV1', + 'RearPortFilterV1', + 'RearPortTemplateFilterV1', + 'RegionFilterV1', + 'SiteFilterV1', + 'SiteGroupFilterV1', + 'VirtualChassisFilterV1', + 'VirtualDeviceContextFilterV1', +) + + +@strawberry_django.filter_type(models.Cable, lookups=True) +class CableFilterV1(PrimaryModelFilterMixinV1, TenancyFilterMixinV1): + type: Annotated['CableTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + status: Annotated['LinkStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + label: FilterLookup[str] | None = strawberry_django.filter_field() + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + length: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + length_unit: Annotated['CableLengthUnitEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + terminations: Annotated['CableTerminationFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.CableTermination, lookups=True) +class CableTerminationFilterV1(ChangeLogFilterMixinV1): + cable: Annotated['CableFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + cable_id: ID | None = strawberry_django.filter_field() + cable_end: Annotated['CableEndEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + termination_type: Annotated['CableTerminationFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + termination_id: ID | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.ConsolePort, lookups=True) +class ConsolePortFilterV1(ModularComponentModelFilterMixinV1, CabledObjectModelFilterMixinV1): + type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + speed: Annotated['ConsolePortSpeedEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.ConsolePortTemplate, lookups=True) +class ConsolePortTemplateFilterV1(ModularComponentTemplateFilterMixinV1): + type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.ConsoleServerPort, lookups=True) +class ConsoleServerPortFilterV1(ModularComponentModelFilterMixinV1, CabledObjectModelFilterMixinV1): + type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + speed: Annotated['ConsolePortSpeedEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.ConsoleServerPortTemplate, lookups=True) +class ConsoleServerPortTemplateFilterV1(ModularComponentTemplateFilterMixinV1): + type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.Device, lookups=True) +class DeviceFilterV1( + ContactFilterMixinV1, + TenancyFilterMixinV1, + ImageAttachmentFilterMixinV1, + RenderConfigFilterMixinV1, + ConfigContextFilterMixinV1, + PrimaryModelFilterMixinV1, +): + device_type: Annotated['DeviceTypeFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + device_type_id: ID | None = strawberry_django.filter_field() + role: Annotated['DeviceRoleFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + role_id: ID | None = strawberry_django.filter_field() + platform: Annotated['PlatformFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + name: FilterLookup[str] | None = strawberry_django.filter_field() + serial: FilterLookup[str] | None = strawberry_django.filter_field() + asset_tag: FilterLookup[str] | None = strawberry_django.filter_field() + site: Annotated['SiteFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + site_id: ID | None = strawberry_django.filter_field() + location: Annotated['LocationFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + rack: Annotated['RackFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + rack_id: ID | None = strawberry_django.filter_field() + position: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + face: Annotated['DeviceFaceEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + status: Annotated['DeviceStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + airflow: Annotated['DeviceAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + primary_ip4: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + primary_ip4_id: ID | None = strawberry_django.filter_field() + primary_ip6: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + primary_ip6_id: ID | None = strawberry_django.filter_field() + oob_ip: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + oob_ip_id: ID | None = strawberry_django.filter_field() + cluster: Annotated['ClusterFilterV1', strawberry.lazy('virtualization.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + cluster_id: ID | None = strawberry_django.filter_field() + virtual_chassis: Annotated['VirtualChassisFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + virtual_chassis_id: ID | None = strawberry_django.filter_field() + vc_position: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + vc_priority: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + latitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + longitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + console_ports: Annotated['ConsolePortFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + console_server_ports: Annotated['ConsoleServerPortFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + power_outlets: Annotated['PowerOutletFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + power_ports: Annotated['PowerPortFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + interfaces: Annotated['InterfaceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + front_ports: Annotated['FrontPortFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + rear_ports: Annotated['RearPortFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + device_bays: Annotated['DeviceBayFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + module_bays: Annotated['ModuleBayFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + modules: Annotated['ModuleFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + console_port_count: FilterLookup[int] | None = strawberry_django.filter_field() + console_server_port_count: FilterLookup[int] | None = strawberry_django.filter_field() + power_port_count: FilterLookup[int] | None = strawberry_django.filter_field() + power_outlet_count: FilterLookup[int] | None = strawberry_django.filter_field() + interface_count: FilterLookup[int] | None = strawberry_django.filter_field() + front_port_count: FilterLookup[int] | None = strawberry_django.filter_field() + rear_port_count: FilterLookup[int] | None = strawberry_django.filter_field() + device_bay_count: FilterLookup[int] | None = strawberry_django.filter_field() + module_bay_count: FilterLookup[int] | None = strawberry_django.filter_field() + inventory_item_count: FilterLookup[int] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.DeviceBay, lookups=True) +class DeviceBayFilterV1(ComponentModelFilterMixinV1): + installed_device: Annotated['DeviceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + installed_device_id: ID | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.DeviceBayTemplate, lookups=True) +class DeviceBayTemplateFilterV1(ComponentTemplateFilterMixinV1): + pass + + +@strawberry_django.filter_type(models.InventoryItemTemplate, lookups=True) +class InventoryItemTemplateFilterV1(ComponentTemplateFilterMixinV1): + parent: Annotated['InventoryItemTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + component_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + component_id: ID | None = strawberry_django.filter_field() + role: Annotated['InventoryItemRoleFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + role_id: ID | None = strawberry_django.filter_field() + manufacturer: Annotated['ManufacturerFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + manufacturer_id: ID | None = strawberry_django.filter_field() + part_id: FilterLookup[str] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.DeviceRole, lookups=True) +class DeviceRoleFilterV1(OrganizationalModelFilterMixinV1, RenderConfigFilterMixinV1): + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + vm_role: FilterLookup[bool] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.DeviceType, lookups=True) +class DeviceTypeFilterV1(ImageAttachmentFilterMixinV1, PrimaryModelFilterMixinV1, WeightFilterMixinV1): + manufacturer: Annotated['ManufacturerFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + manufacturer_id: ID | None = strawberry_django.filter_field() + model: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() + default_platform: Annotated['PlatformFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + default_platform_id: ID | None = strawberry_django.filter_field() + part_number: FilterLookup[str] | None = strawberry_django.filter_field() + u_height: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + exclude_from_utilization: FilterLookup[bool] | None = strawberry_django.filter_field() + is_full_depth: FilterLookup[bool] | None = strawberry_django.filter_field() + subdevice_role: Annotated['SubdeviceRoleEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + airflow: Annotated['DeviceAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + front_image: Annotated['ImageAttachmentFilterV1', strawberry.lazy('extras.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + rear_image: Annotated['ImageAttachmentFilterV1', strawberry.lazy('extras.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + console_port_templates: ( + Annotated['ConsolePortTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None + ) = strawberry_django.filter_field() + console_server_port_templates: ( + Annotated['ConsoleServerPortTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None + ) = strawberry_django.filter_field() + power_port_templates: ( + Annotated['PowerPortTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None + ) = strawberry_django.filter_field() + power_outlet_templates: ( + Annotated['PowerOutletTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None + ) = strawberry_django.filter_field() + interface_templates: ( + Annotated['InterfaceTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None + ) = strawberry_django.filter_field() + front_port_templates: ( + Annotated['FrontPortTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None + ) = strawberry_django.filter_field() + rear_port_templates: ( + Annotated['RearPortTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None + ) = strawberry_django.filter_field() + device_bay_templates: ( + Annotated['DeviceBayTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None + ) = strawberry_django.filter_field() + module_bay_templates: ( + Annotated['ModuleBayTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None + ) = strawberry_django.filter_field() + inventory_item_templates: ( + Annotated['InventoryItemTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None + ) = strawberry_django.filter_field() + console_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + console_server_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + power_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + power_outlet_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + interface_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + front_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + rear_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + device_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + module_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + inventory_item_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.FrontPort, lookups=True) +class FrontPortFilterV1(ModularComponentModelFilterMixinV1, CabledObjectModelFilterMixinV1): + type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + rear_port: Annotated['RearPortFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + rear_port_id: ID | None = strawberry_django.filter_field() + rear_port_position: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.FrontPortTemplate, lookups=True) +class FrontPortTemplateFilterV1(ModularComponentTemplateFilterMixinV1): + type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + rear_port: Annotated['RearPortTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + rear_port_id: ID | None = strawberry_django.filter_field() + rear_port_position: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.MACAddress, lookups=True) +class MACAddressFilterV1(PrimaryModelFilterMixinV1): + mac_address: FilterLookup[str] | None = strawberry_django.filter_field() + assigned_object_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + assigned_object_id: ID | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.Interface, lookups=True) +class InterfaceFilterV1(ModularComponentModelFilterMixinV1, InterfaceBaseFilterMixinV1, CabledObjectModelFilterMixinV1): + vcdcs: Annotated['VirtualDeviceContextFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + lag: Annotated['InterfaceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + lag_id: ID | None = strawberry_django.filter_field() + type: Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + mgmt_only: FilterLookup[bool] | None = strawberry_django.filter_field() + speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + duplex: Annotated['InterfaceDuplexEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + wwn: FilterLookup[str] | None = strawberry_django.filter_field() + parent: Annotated['InterfaceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + parent_id: ID | None = strawberry_django.filter_field() + rf_role: Annotated['WirelessRoleEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + rf_channel: Annotated['WirelessChannelEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + rf_channel_frequency: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + rf_channel_width: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + tx_power: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + poe_mode: Annotated['InterfacePoEModeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + poe_type: Annotated['InterfacePoETypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + wireless_link: Annotated['WirelessLinkFilterV1', strawberry.lazy('wireless.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + wireless_link_id: ID | None = strawberry_django.filter_field() + wireless_lans: Annotated['WirelessLANFilterV1', strawberry.lazy('wireless.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + vrf: Annotated['VRFFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = strawberry_django.filter_field() + vrf_id: ID | None = strawberry_django.filter_field() + ip_addresses: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + mac_addresses: Annotated['MACAddressFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + fhrp_group_assignments: Annotated[ + 'FHRPGroupAssignmentFilterV1', strawberry.lazy('ipam.graphql.filters_v1') + ] | None = ( + strawberry_django.filter_field() + ) + tunnel_terminations: Annotated['TunnelTerminationFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + l2vpn_terminations: Annotated['L2VPNFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + @strawberry_django.filter_field + def connected(self, queryset, value: bool, prefix: str): + if value is True: + return queryset, Q(**{f"{prefix}_path__is_active": True}) + else: + return queryset, Q(**{f"{prefix}_path__isnull": True}) | Q(**{f"{prefix}_path__is_active": False}) + + @strawberry_django.filter_field + def kind( + self, + queryset, + value: Annotated['InterfaceKindEnum', strawberry.lazy('dcim.graphql.enums')], + prefix: str + ): + if value == InterfaceKindEnum.KIND_PHYSICAL: + return queryset, ~Q(**{f"{prefix}type__in": NONCONNECTABLE_IFACE_TYPES}) + elif value == InterfaceKindEnum.KIND_VIRTUAL: + return queryset, Q(**{f"{prefix}type__in": VIRTUAL_IFACE_TYPES}) + elif value == InterfaceKindEnum.KIND_WIRELESS: + return queryset, Q(**{f"{prefix}type__in": WIRELESS_IFACE_TYPES}) + + +@strawberry_django.filter_type(models.InterfaceTemplate, lookups=True) +class InterfaceTemplateFilterV1(ModularComponentTemplateFilterMixinV1): + type: Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + enabled: FilterLookup[bool] | None = strawberry_django.filter_field() + mgmt_only: FilterLookup[bool] | None = strawberry_django.filter_field() + bridge: Annotated['InterfaceTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + bridge_id: ID | None = strawberry_django.filter_field() + poe_mode: Annotated['InterfacePoEModeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + poe_type: Annotated['InterfacePoETypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + rf_role: Annotated['WirelessRoleEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.InventoryItem, lookups=True) +class InventoryItemFilterV1(ComponentModelFilterMixinV1): + parent: Annotated['InventoryItemFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + parent_id: ID | None = strawberry_django.filter_field() + component_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + component_id: ID | None = strawberry_django.filter_field() + status: Annotated['InventoryItemStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + role: Annotated['InventoryItemRoleFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + role_id: ID | None = strawberry_django.filter_field() + manufacturer: Annotated['ManufacturerFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + manufacturer_id: ID | None = strawberry_django.filter_field() + part_id: FilterLookup[str] | None = strawberry_django.filter_field() + serial: FilterLookup[str] | None = strawberry_django.filter_field() + asset_tag: FilterLookup[str] | None = strawberry_django.filter_field() + discovered: FilterLookup[bool] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.InventoryItemRole, lookups=True) +class InventoryItemRoleFilterV1(OrganizationalModelFilterMixinV1): + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.Location, lookups=True) +class LocationFilterV1( + ContactFilterMixinV1, ImageAttachmentFilterMixinV1, TenancyFilterMixinV1, NestedGroupModelFilterMixinV1 +): + site: Annotated['SiteFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + site_id: ID | None = strawberry_django.filter_field() + status: Annotated['LocationStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + facility: FilterLookup[str] | None = strawberry_django.filter_field() + prefixes: Annotated['PrefixFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + vlan_groups: Annotated['VLANGroupFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.Manufacturer, lookups=True) +class ManufacturerFilterV1(ContactFilterMixinV1, OrganizationalModelFilterMixinV1): + pass + + +@strawberry_django.filter_type(models.Module, lookups=True) +class ModuleFilterV1(PrimaryModelFilterMixinV1, ConfigContextFilterMixinV1): + device: Annotated['DeviceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + device_id: ID | None = strawberry_django.filter_field() + module_bay: Annotated['ModuleBayFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + module_bay_id: ID | None = strawberry_django.filter_field() + module_type: Annotated['ModuleTypeFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + module_type_id: ID | None = strawberry_django.filter_field() + status: Annotated['ModuleStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + serial: FilterLookup[str] | None = strawberry_django.filter_field() + asset_tag: FilterLookup[str] | None = strawberry_django.filter_field() + console_ports: Annotated['ConsolePortFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + console_server_ports: Annotated['ConsoleServerPortFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + power_outlets: Annotated['PowerOutletFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + power_ports: Annotated['PowerPortFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + interfaces: Annotated['InterfaceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + front_ports: Annotated['FrontPortFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + rear_ports: Annotated['RearPortFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + device_bays: Annotated['DeviceBayFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + module_bays: Annotated['ModuleBayFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + modules: Annotated['ModuleFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.ModuleBay, lookups=True) +class ModuleBayFilterV1(ModularComponentModelFilterMixinV1): + parent: Annotated['ModuleBayFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + parent_id: ID | None = strawberry_django.filter_field() + position: FilterLookup[str] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.ModuleBayTemplate, lookups=True) +class ModuleBayTemplateFilterV1(ModularComponentTemplateFilterMixinV1): + position: FilterLookup[str] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.ModuleTypeProfile, lookups=True) +class ModuleTypeProfileFilterV1(PrimaryModelFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.ModuleType, lookups=True) +class ModuleTypeFilterV1(ImageAttachmentFilterMixinV1, PrimaryModelFilterMixinV1, WeightFilterMixinV1): + manufacturer: Annotated['ManufacturerFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + manufacturer_id: ID | None = strawberry_django.filter_field() + profile: Annotated['ModuleTypeProfileFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + profile_id: ID | None = strawberry_django.filter_field() + model: FilterLookup[str] | None = strawberry_django.filter_field() + part_number: FilterLookup[str] | None = strawberry_django.filter_field() + airflow: Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + console_port_templates: ( + Annotated['ConsolePortTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None + ) = strawberry_django.filter_field() + console_server_port_templates: ( + Annotated['ConsoleServerPortTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None + ) = strawberry_django.filter_field() + power_port_templates: ( + Annotated['PowerPortTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None + ) = strawberry_django.filter_field() + power_outlet_templates: ( + Annotated['PowerOutletTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None + ) = strawberry_django.filter_field() + interface_templates: ( + Annotated['InterfaceTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None + ) = strawberry_django.filter_field() + front_port_templates: ( + Annotated['FrontPortTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None + ) = strawberry_django.filter_field() + rear_port_templates: ( + Annotated['RearPortTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None + ) = strawberry_django.filter_field() + device_bay_templates: ( + Annotated['DeviceBayTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None + ) = strawberry_django.filter_field() + module_bay_templates: ( + Annotated['ModuleBayTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None + ) = strawberry_django.filter_field() + inventory_item_templates: ( + Annotated['InventoryItemTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None + ) = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.Platform, lookups=True) +class PlatformFilterV1(OrganizationalModelFilterMixinV1): + manufacturer: Annotated['ManufacturerFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + manufacturer_id: ID | None = strawberry_django.filter_field() + config_template: Annotated['ConfigTemplateFilterV1', strawberry.lazy('extras.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + config_template_id: ID | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.PowerFeed, lookups=True) +class PowerFeedFilterV1(CabledObjectModelFilterMixinV1, TenancyFilterMixinV1, PrimaryModelFilterMixinV1): + power_panel: Annotated['PowerPanelFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + power_panel_id: ID | None = strawberry_django.filter_field() + rack: Annotated['RackFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + rack_id: ID | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() + status: Annotated['PowerFeedStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + type: Annotated['PowerFeedTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + supply: Annotated['PowerFeedSupplyEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + phase: Annotated['PowerFeedPhaseEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + voltage: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + amperage: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + max_utilization: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + available_power: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.PowerOutlet, lookups=True) +class PowerOutletFilterV1(ModularComponentModelFilterMixinV1, CabledObjectModelFilterMixinV1): + type: Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + power_port: Annotated['PowerPortFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + power_port_id: ID | None = strawberry_django.filter_field() + feed_leg: Annotated['PowerOutletFeedLegEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.PowerOutletTemplate, lookups=True) +class PowerOutletTemplateFilterV1(ModularComponentModelFilterMixinV1): + type: Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + power_port: Annotated['PowerPortTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + power_port_id: ID | None = strawberry_django.filter_field() + feed_leg: Annotated['PowerOutletFeedLegEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.PowerPanel, lookups=True) +class PowerPanelFilterV1(ContactFilterMixinV1, ImageAttachmentFilterMixinV1, PrimaryModelFilterMixinV1): + site: Annotated['SiteFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + site_id: ID | None = strawberry_django.filter_field() + location: Annotated['LocationFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + name: FilterLookup[str] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.PowerPort, lookups=True) +class PowerPortFilterV1(ModularComponentModelFilterMixinV1, CabledObjectModelFilterMixinV1): + type: Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + maximum_draw: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + allocated_draw: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.PowerPortTemplate, lookups=True) +class PowerPortTemplateFilterV1(ModularComponentTemplateFilterMixinV1): + type: Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + maximum_draw: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + allocated_draw: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.RackType, lookups=True) +class RackTypeFilterV1(RackBaseFilterMixinV1): + form_factor: Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + manufacturer: Annotated['ManufacturerFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + manufacturer_id: ID | None = strawberry_django.filter_field() + model: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.Rack, lookups=True) +class RackFilterV1(ContactFilterMixinV1, ImageAttachmentFilterMixinV1, TenancyFilterMixinV1, RackBaseFilterMixinV1): + form_factor: Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + rack_type: Annotated['RackTypeFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + rack_type_id: ID | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() + facility_id: FilterLookup[str] | None = strawberry_django.filter_field() + site: Annotated['SiteFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + site_id: ID | None = strawberry_django.filter_field() + location: Annotated['LocationFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + status: Annotated['RackStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + role: Annotated['RackRoleFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + role_id: ID | None = strawberry_django.filter_field() + serial: FilterLookup[str] | None = strawberry_django.filter_field() + asset_tag: FilterLookup[str] | None = strawberry_django.filter_field() + airflow: Annotated['RackAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + vlan_groups: Annotated['VLANGroupFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.RackReservation, lookups=True) +class RackReservationFilterV1(TenancyFilterMixinV1, PrimaryModelFilterMixinV1): + rack: Annotated['RackFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + rack_id: ID | None = strawberry_django.filter_field() + units: Annotated['IntegerArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + user: Annotated['UserFilterV1', strawberry.lazy('users.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + user_id: ID | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.RackRole, lookups=True) +class RackRoleFilterV1(OrganizationalModelFilterMixinV1): + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.RearPort, lookups=True) +class RearPortFilterV1(ModularComponentModelFilterMixinV1, CabledObjectModelFilterMixinV1): + type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + positions: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.RearPortTemplate, lookups=True) +class RearPortTemplateFilterV1(ModularComponentTemplateFilterMixinV1): + type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + positions: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.Region, lookups=True) +class RegionFilterV1(ContactFilterMixinV1, NestedGroupModelFilterMixinV1): + prefixes: Annotated['PrefixFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + vlan_groups: Annotated['VLANGroupFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.Site, lookups=True) +class SiteFilterV1(ContactFilterMixinV1, ImageAttachmentFilterMixinV1, TenancyFilterMixinV1, PrimaryModelFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() + status: Annotated['SiteStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + region: Annotated['RegionFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + region_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + group: Annotated['SiteGroupFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + facility: FilterLookup[str] | None = strawberry_django.filter_field() + asns: Annotated['ASNFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = strawberry_django.filter_field() + time_zone: FilterLookup[str] | None = strawberry_django.filter_field() + physical_address: FilterLookup[str] | None = strawberry_django.filter_field() + shipping_address: FilterLookup[str] | None = strawberry_django.filter_field() + latitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + longitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + prefixes: Annotated['PrefixFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + vlan_groups: Annotated['VLANGroupFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.SiteGroup, lookups=True) +class SiteGroupFilterV1(ContactFilterMixinV1, NestedGroupModelFilterMixinV1): + prefixes: Annotated['PrefixFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + vlan_groups: Annotated['VLANGroupFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.VirtualChassis, lookups=True) +class VirtualChassisFilterV1(PrimaryModelFilterMixinV1): + master: Annotated['DeviceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + master_id: ID | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() + domain: FilterLookup[str] | None = strawberry_django.filter_field() + members: ( + Annotated['DeviceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None + ) = strawberry_django.filter_field() + member_count: FilterLookup[int] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.VirtualDeviceContext, lookups=True) +class VirtualDeviceContextFilterV1(TenancyFilterMixinV1, PrimaryModelFilterMixinV1): + device: Annotated['DeviceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + device_id: ID | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() + status: Annotated['VirtualDeviceContextStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + identifier: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + primary_ip4: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + primary_ip4_id: ID | None = strawberry_django.filter_field() + primary_ip6: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + primary_ip6_id: ID | None = strawberry_django.filter_field() + comments: FilterLookup[str] | None = strawberry_django.filter_field() + interfaces: ( + Annotated['InterfaceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None + ) = strawberry_django.filter_field() diff --git a/netbox/dcim/graphql/gfk_mixins_v1.py b/netbox/dcim/graphql/gfk_mixins_v1.py new file mode 100644 index 000000000..378b3a916 --- /dev/null +++ b/netbox/dcim/graphql/gfk_mixins_v1.py @@ -0,0 +1,137 @@ +from strawberry.types import Info + +from circuits.graphql.types_v1 import CircuitTerminationTypeV1, ProviderNetworkTypeV1 +from circuits.models import CircuitTermination, ProviderNetwork +from dcim.graphql.types_v1 import ( + ConsolePortTemplateTypeV1, + ConsolePortTypeV1, + ConsoleServerPortTemplateTypeV1, + ConsoleServerPortTypeV1, + FrontPortTemplateTypeV1, + FrontPortTypeV1, + InterfaceTemplateTypeV1, + InterfaceTypeV1, + PowerFeedTypeV1, + PowerOutletTemplateTypeV1, + PowerOutletTypeV1, + PowerPortTemplateTypeV1, + PowerPortTypeV1, + RearPortTemplateTypeV1, + RearPortTypeV1, +) +from dcim.models import ( + ConsolePort, + ConsolePortTemplate, + ConsoleServerPort, + ConsoleServerPortTemplate, + FrontPort, + FrontPortTemplate, + Interface, + InterfaceTemplate, + PowerFeed, + PowerOutlet, + PowerOutletTemplate, + PowerPort, + PowerPortTemplate, + RearPort, + RearPortTemplate, +) + + +class InventoryItemTemplateComponentTypeV1: + class Meta: + types = ( + ConsolePortTemplateTypeV1, + ConsoleServerPortTemplateTypeV1, + FrontPortTemplateTypeV1, + InterfaceTemplateTypeV1, + PowerOutletTemplateTypeV1, + PowerPortTemplateTypeV1, + RearPortTemplateTypeV1, + ) + + @classmethod + def resolve_type(cls, instance, info: Info): + if type(instance) is ConsolePortTemplate: + return ConsolePortTemplateTypeV1 + if type(instance) is ConsoleServerPortTemplate: + return ConsoleServerPortTemplateTypeV1 + if type(instance) is FrontPortTemplate: + return FrontPortTemplateTypeV1 + if type(instance) is InterfaceTemplate: + return InterfaceTemplateTypeV1 + if type(instance) is PowerOutletTemplate: + return PowerOutletTemplateTypeV1 + if type(instance) is PowerPortTemplate: + return PowerPortTemplateTypeV1 + if type(instance) is RearPortTemplate: + return RearPortTemplateTypeV1 + + +class InventoryItemComponentTypeV1: + class Meta: + types = ( + ConsolePortTypeV1, + ConsoleServerPortTypeV1, + FrontPortTypeV1, + InterfaceTypeV1, + PowerOutletTypeV1, + PowerPortTypeV1, + RearPortTypeV1, + ) + + @classmethod + def resolve_type(cls, instance, info: Info): + if type(instance) is ConsolePort: + return ConsolePortTypeV1 + if type(instance) is ConsoleServerPort: + return ConsoleServerPortTypeV1 + if type(instance) is FrontPort: + return FrontPortTypeV1 + if type(instance) is Interface: + return InterfaceTypeV1 + if type(instance) is PowerOutlet: + return PowerOutletTypeV1 + if type(instance) is PowerPort: + return PowerPortTypeV1 + if type(instance) is RearPort: + return RearPortTypeV1 + + +class ConnectedEndpointTypeV1: + class Meta: + types = ( + CircuitTerminationTypeV1, + ConsolePortTypeV1, + ConsoleServerPortTypeV1, + FrontPortTypeV1, + InterfaceTypeV1, + PowerFeedTypeV1, + PowerOutletTypeV1, + PowerPortTypeV1, + ProviderNetworkTypeV1, + RearPortTypeV1, + ) + + @classmethod + def resolve_type(cls, instance, info: Info): + if type(instance) is CircuitTermination: + return CircuitTerminationTypeV1 + if type(instance) is ConsolePort: + return ConsolePortTypeV1 + if type(instance) is ConsoleServerPort: + return ConsoleServerPortTypeV1 + if type(instance) is FrontPort: + return FrontPortTypeV1 + if type(instance) is Interface: + return InterfaceTypeV1 + if type(instance) is PowerFeed: + return PowerFeedTypeV1 + if type(instance) is PowerOutlet: + return PowerOutletTypeV1 + if type(instance) is PowerPort: + return PowerPortTypeV1 + if type(instance) is ProviderNetwork: + return ProviderNetworkTypeV1 + if type(instance) is RearPort: + return RearPortTypeV1 diff --git a/netbox/dcim/graphql/mixins_v1.py b/netbox/dcim/graphql/mixins_v1.py new file mode 100644 index 000000000..694283e3c --- /dev/null +++ b/netbox/dcim/graphql/mixins_v1.py @@ -0,0 +1,43 @@ +from typing import Annotated, List, Union + +import strawberry + +__all__ = ( + 'CabledObjectMixinV1', + 'PathEndpointMixinV1', +) + + +@strawberry.type +class CabledObjectMixinV1: + cable: Annotated["CableTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None # noqa: F821 + + link_peers: List[Annotated[Union[ + Annotated["CircuitTerminationTypeV1", strawberry.lazy('circuits.graphql.types_v1')], # noqa: F821 + Annotated["ConsolePortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 + Annotated["ConsoleServerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 + Annotated["FrontPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 + Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 + Annotated["PowerFeedTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 + Annotated["PowerOutletTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 + Annotated["PowerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 + Annotated["RearPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 + ], strawberry.union("LinkPeerType")]] + + +@strawberry.type +class PathEndpointMixinV1: + + connected_endpoints: List[Annotated[Union[ + Annotated["CircuitTerminationTypeV1", strawberry.lazy('circuits.graphql.types_v1')], # noqa: F821 + Annotated["VirtualCircuitTerminationTypeV1", strawberry.lazy('circuits.graphql.types_v1')], # noqa: F821 + Annotated["ConsolePortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 + Annotated["ConsoleServerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 + Annotated["FrontPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 + Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 + Annotated["PowerFeedTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 + Annotated["PowerOutletTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 + Annotated["PowerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 + Annotated["ProviderNetworkTypeV1", strawberry.lazy('circuits.graphql.types_v1')], # noqa: F821 + Annotated["RearPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 + ], strawberry.union("ConnectedEndpointTypeV1")]] diff --git a/netbox/dcim/graphql/schema_v1.py b/netbox/dcim/graphql/schema_v1.py new file mode 100644 index 000000000..29d2f09ba --- /dev/null +++ b/netbox/dcim/graphql/schema_v1.py @@ -0,0 +1,138 @@ +from typing import List + +import strawberry +import strawberry_django + +from .types_v1 import * + + +@strawberry.type(name="Query") +class DCIMQueryV1: + cable: CableTypeV1 = strawberry_django.field() + cable_list: List[CableTypeV1] = strawberry_django.field() + + console_port: ConsolePortTypeV1 = strawberry_django.field() + console_port_list: List[ConsolePortTypeV1] = strawberry_django.field() + + console_port_template: ConsolePortTemplateTypeV1 = strawberry_django.field() + console_port_template_list: List[ConsolePortTemplateTypeV1] = strawberry_django.field() + + console_server_port: ConsoleServerPortTypeV1 = strawberry_django.field() + console_server_port_list: List[ConsoleServerPortTypeV1] = strawberry_django.field() + + console_server_port_template: ConsoleServerPortTemplateTypeV1 = strawberry_django.field() + console_server_port_template_list: List[ConsoleServerPortTemplateTypeV1] = strawberry_django.field() + + device: DeviceTypeV1 = strawberry_django.field() + device_list: List[DeviceTypeV1] = strawberry_django.field() + + device_bay: DeviceBayTypeV1 = strawberry_django.field() + device_bay_list: List[DeviceBayTypeV1] = strawberry_django.field() + + device_bay_template: DeviceBayTemplateTypeV1 = strawberry_django.field() + device_bay_template_list: List[DeviceBayTemplateTypeV1] = strawberry_django.field() + + device_role: DeviceRoleTypeV1 = strawberry_django.field() + device_role_list: List[DeviceRoleTypeV1] = strawberry_django.field() + + device_type: DeviceTypeTypeV1 = strawberry_django.field() + device_type_list: List[DeviceTypeTypeV1] = strawberry_django.field() + + front_port: FrontPortTypeV1 = strawberry_django.field() + front_port_list: List[FrontPortTypeV1] = strawberry_django.field() + + front_port_template: FrontPortTemplateTypeV1 = strawberry_django.field() + front_port_template_list: List[FrontPortTemplateTypeV1] = strawberry_django.field() + + mac_address: MACAddressTypeV1 = strawberry_django.field() + mac_address_list: List[MACAddressTypeV1] = strawberry_django.field() + + interface: InterfaceTypeV1 = strawberry_django.field() + interface_list: List[InterfaceTypeV1] = strawberry_django.field() + + interface_template: InterfaceTemplateTypeV1 = strawberry_django.field() + interface_template_list: List[InterfaceTemplateTypeV1] = strawberry_django.field() + + inventory_item: InventoryItemTypeV1 = strawberry_django.field() + inventory_item_list: List[InventoryItemTypeV1] = strawberry_django.field() + + inventory_item_role: InventoryItemRoleTypeV1 = strawberry_django.field() + inventory_item_role_list: List[InventoryItemRoleTypeV1] = strawberry_django.field() + + inventory_item_template: InventoryItemTemplateTypeV1 = strawberry_django.field() + inventory_item_template_list: List[InventoryItemTemplateTypeV1] = strawberry_django.field() + + location: LocationTypeV1 = strawberry_django.field() + location_list: List[LocationTypeV1] = strawberry_django.field() + + manufacturer: ManufacturerTypeV1 = strawberry_django.field() + manufacturer_list: List[ManufacturerTypeV1] = strawberry_django.field() + + module: ModuleTypeV1 = strawberry_django.field() + module_list: List[ModuleTypeV1] = strawberry_django.field() + + module_bay: ModuleBayTypeV1 = strawberry_django.field() + module_bay_list: List[ModuleBayTypeV1] = strawberry_django.field() + + module_bay_template: ModuleBayTemplateTypeV1 = strawberry_django.field() + module_bay_template_list: List[ModuleBayTemplateTypeV1] = strawberry_django.field() + + module_type_profile: ModuleTypeProfileTypeV1 = strawberry_django.field() + module_type_profile_list: List[ModuleTypeProfileTypeV1] = strawberry_django.field() + + module_type: ModuleTypeTypeV1 = strawberry_django.field() + module_type_list: List[ModuleTypeTypeV1] = strawberry_django.field() + + platform: PlatformTypeV1 = strawberry_django.field() + platform_list: List[PlatformTypeV1] = strawberry_django.field() + + power_feed: PowerFeedTypeV1 = strawberry_django.field() + power_feed_list: List[PowerFeedTypeV1] = strawberry_django.field() + + power_outlet: PowerOutletTypeV1 = strawberry_django.field() + power_outlet_list: List[PowerOutletTypeV1] = strawberry_django.field() + + power_outlet_template: PowerOutletTemplateTypeV1 = strawberry_django.field() + power_outlet_template_list: List[PowerOutletTemplateTypeV1] = strawberry_django.field() + + power_panel: PowerPanelTypeV1 = strawberry_django.field() + power_panel_list: List[PowerPanelTypeV1] = strawberry_django.field() + + power_port: PowerPortTypeV1 = strawberry_django.field() + power_port_list: List[PowerPortTypeV1] = strawberry_django.field() + + power_port_template: PowerPortTemplateTypeV1 = strawberry_django.field() + power_port_template_list: List[PowerPortTemplateTypeV1] = strawberry_django.field() + + rack_type: RackTypeTypeV1 = strawberry_django.field() + rack_type_list: List[RackTypeTypeV1] = strawberry_django.field() + + rack: RackTypeV1 = strawberry_django.field() + rack_list: List[RackTypeV1] = strawberry_django.field() + + rack_reservation: RackReservationTypeV1 = strawberry_django.field() + rack_reservation_list: List[RackReservationTypeV1] = strawberry_django.field() + + rack_role: RackRoleTypeV1 = strawberry_django.field() + rack_role_list: List[RackRoleTypeV1] = strawberry_django.field() + + rear_port: RearPortTypeV1 = strawberry_django.field() + rear_port_list: List[RearPortTypeV1] = strawberry_django.field() + + rear_port_template: RearPortTemplateTypeV1 = strawberry_django.field() + rear_port_template_list: List[RearPortTemplateTypeV1] = strawberry_django.field() + + region: RegionTypeV1 = strawberry_django.field() + region_list: List[RegionTypeV1] = strawberry_django.field() + + site: SiteTypeV1 = strawberry_django.field() + site_list: List[SiteTypeV1] = strawberry_django.field() + + site_group: SiteGroupTypeV1 = strawberry_django.field() + site_group_list: List[SiteGroupTypeV1] = strawberry_django.field() + + virtual_chassis: VirtualChassisTypeV1 = strawberry_django.field() + virtual_chassis_list: List[VirtualChassisTypeV1] = strawberry_django.field() + + virtual_device_context: VirtualDeviceContextTypeV1 = strawberry_django.field() + virtual_device_context_list: List[VirtualDeviceContextTypeV1] = strawberry_django.field() diff --git a/netbox/dcim/graphql/types_v1.py b/netbox/dcim/graphql/types_v1.py new file mode 100644 index 000000000..c7f069ecb --- /dev/null +++ b/netbox/dcim/graphql/types_v1.py @@ -0,0 +1,906 @@ +from typing import Annotated, List, TYPE_CHECKING, Union + +import strawberry +import strawberry_django + +from core.graphql.mixins_v1 import ChangelogMixinV1 +from dcim import models +from extras.graphql.mixins_v1 import ( + ConfigContextMixinV1, + ContactsMixinV1, + CustomFieldsMixinV1, + ImageAttachmentsMixinV1, + TagsMixinV1, +) +from ipam.graphql.mixins_v1 import IPAddressesMixinV1, VLANGroupsMixinV1 +from netbox.graphql.scalars import BigInt +from netbox.graphql.types_v1 import BaseObjectTypeV1, NetBoxObjectTypeV1, OrganizationalObjectTypeV1 +from .filters_v1 import * +from .mixins_v1 import CabledObjectMixinV1, PathEndpointMixinV1 + +if TYPE_CHECKING: + from circuits.graphql.types_v1 import CircuitTerminationTypeV1 + from extras.graphql.types_v1 import ConfigTemplateTypeV1 + from ipam.graphql.types_v1 import ( + ASNTypeV1, + IPAddressTypeV1, + PrefixTypeV1, + ServiceTypeV1, + VLANTranslationPolicyTypeV1, + VLANTypeV1, + VRFTypeV1, + ) + from tenancy.graphql.types_v1 import TenantTypeV1 + from users.graphql.types_v1 import UserTypeV1 + from virtualization.graphql.types_v1 import ClusterTypeV1, VMInterfaceTypeV1, VirtualMachineTypeV1 + from vpn.graphql.types_v1 import L2VPNTerminationTypeV1 + from wireless.graphql.types_v1 import WirelessLANTypeV1, WirelessLinkTypeV1 + +__all__ = ( + 'CableTypeV1', + 'ComponentTypeV1', + 'ConsolePortTypeV1', + 'ConsolePortTemplateTypeV1', + 'ConsoleServerPortTypeV1', + 'ConsoleServerPortTemplateTypeV1', + 'DeviceTypeV1', + 'DeviceBayTypeV1', + 'DeviceBayTemplateTypeV1', + 'DeviceRoleTypeV1', + 'DeviceTypeTypeV1', + 'FrontPortTypeV1', + 'FrontPortTemplateTypeV1', + 'InterfaceTypeV1', + 'InterfaceTemplateTypeV1', + 'InventoryItemTypeV1', + 'InventoryItemRoleTypeV1', + 'InventoryItemTemplateTypeV1', + 'LocationTypeV1', + 'MACAddressTypeV1', + 'ManufacturerTypeV1', + 'ModularComponentTypeV1', + 'ModuleTypeV1', + 'ModuleBayTypeV1', + 'ModuleBayTemplateTypeV1', + 'ModuleTypeProfileTypeV1', + 'ModuleTypeTypeV1', + 'PlatformTypeV1', + 'PowerFeedTypeV1', + 'PowerOutletTypeV1', + 'PowerOutletTemplateTypeV1', + 'PowerPanelTypeV1', + 'PowerPortTypeV1', + 'PowerPortTemplateTypeV1', + 'RackTypeV1', + 'RackReservationTypeV1', + 'RackRoleTypeV1', + 'RackTypeTypeV1', + 'RearPortTypeV1', + 'RearPortTemplateTypeV1', + 'RegionTypeV1', + 'SiteTypeV1', + 'SiteGroupTypeV1', + 'VirtualChassisTypeV1', + 'VirtualDeviceContextTypeV1', +) + + +# +# Base types +# + + +@strawberry.type +class ComponentTypeV1( + ChangelogMixinV1, + CustomFieldsMixinV1, + TagsMixinV1, + BaseObjectTypeV1 +): + """ + Base type for device/VM components + """ + device: Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] + + +@strawberry.type +class ModularComponentTypeV1(ComponentTypeV1): + module: Annotated["ModuleTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + + +@strawberry.type +class ComponentTemplateTypeV1( + ChangelogMixinV1, + BaseObjectTypeV1 +): + """ + Base type for device/VM components + """ + device_type: Annotated["DeviceTypeTypeV1", strawberry.lazy('dcim.graphql.types_v1')] + + +@strawberry.type +class ModularComponentTemplateTypeV1(ComponentTemplateTypeV1): + """ + Base type for ComponentTemplateModel which supports optional assignment to a ModuleType. + """ + device_type: Annotated["DeviceTypeTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + module_type: Annotated["ModuleTypeTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + +# +# Model types +# + + +@strawberry_django.type( + models.CableTermination, + exclude=['termination_type', 'termination_id', '_device', '_rack', '_location', '_site'], + filters=CableTerminationFilterV1, + pagination=True +) +class CableTerminationTypeV1(NetBoxObjectTypeV1): + cable: Annotated["CableTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + termination: Annotated[Union[ + Annotated["CircuitTerminationTypeV1", strawberry.lazy('circuits.graphql.types_v1')], + Annotated["ConsolePortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["ConsoleServerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["FrontPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["PowerFeedTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["PowerOutletTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["PowerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["RearPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + ], strawberry.union("CableTerminationTerminationTypeV1")] | None + + +@strawberry_django.type( + models.Cable, + fields='__all__', + filters=CableFilterV1, + pagination=True +) +class CableTypeV1(NetBoxObjectTypeV1): + color: str + tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None + + terminations: List[CableTerminationTypeV1] + + a_terminations: List[Annotated[Union[ + Annotated["CircuitTerminationTypeV1", strawberry.lazy('circuits.graphql.types_v1')], + Annotated["ConsolePortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["ConsoleServerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["FrontPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["PowerFeedTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["PowerOutletTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["PowerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["RearPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + ], strawberry.union("CableTerminationTerminationTypeV1")]] + + b_terminations: List[Annotated[Union[ + Annotated["CircuitTerminationTypeV1", strawberry.lazy('circuits.graphql.types_v1')], + Annotated["ConsolePortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["ConsoleServerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["FrontPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["PowerFeedTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["PowerOutletTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["PowerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["RearPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + ], strawberry.union("CableTerminationTerminationTypeV1")]] + + +@strawberry_django.type( + models.ConsolePort, + exclude=['_path'], + filters=ConsolePortFilterV1, + pagination=True +) +class ConsolePortTypeV1(ModularComponentTypeV1, CabledObjectMixinV1, PathEndpointMixinV1): + pass + + +@strawberry_django.type( + models.ConsolePortTemplate, + fields='__all__', + filters=ConsolePortTemplateFilterV1, + pagination=True +) +class ConsolePortTemplateTypeV1(ModularComponentTemplateTypeV1): + pass + + +@strawberry_django.type( + models.ConsoleServerPort, + exclude=['_path'], + filters=ConsoleServerPortFilterV1, + pagination=True +) +class ConsoleServerPortTypeV1(ModularComponentTypeV1, CabledObjectMixinV1, PathEndpointMixinV1): + pass + + +@strawberry_django.type( + models.ConsoleServerPortTemplate, + fields='__all__', + filters=ConsoleServerPortTemplateFilterV1, + pagination=True +) +class ConsoleServerPortTemplateTypeV1(ModularComponentTemplateTypeV1): + pass + + +@strawberry_django.type( + models.Device, + fields='__all__', + filters=DeviceFilterV1, + pagination=True +) +class DeviceTypeV1(ConfigContextMixinV1, ImageAttachmentsMixinV1, ContactsMixinV1, NetBoxObjectTypeV1): + console_port_count: BigInt + console_server_port_count: BigInt + power_port_count: BigInt + power_outlet_count: BigInt + interface_count: BigInt + front_port_count: BigInt + rear_port_count: BigInt + device_bay_count: BigInt + module_bay_count: BigInt + inventory_item_count: BigInt + config_template: Annotated["ConfigTemplateTypeV1", strawberry.lazy('extras.graphql.types_v1')] | None + device_type: Annotated["DeviceTypeTypeV1", strawberry.lazy('dcim.graphql.types_v1')] + role: Annotated["DeviceRoleTypeV1", strawberry.lazy('dcim.graphql.types_v1')] + tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None + platform: Annotated["PlatformTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + site: Annotated["SiteTypeV1", strawberry.lazy('dcim.graphql.types_v1')] + location: Annotated["LocationTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + rack: Annotated["RackTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + primary_ip4: Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + primary_ip6: Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + oob_ip: Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + cluster: Annotated["ClusterTypeV1", strawberry.lazy('virtualization.graphql.types_v1')] | None + virtual_chassis: Annotated["VirtualChassisTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + + virtual_machines: List[Annotated["VirtualMachineTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] + modules: List[Annotated["ModuleTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + interfaces: List[Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + rearports: List[Annotated["RearPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + consoleports: List[Annotated["ConsolePortTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + powerports: List[Annotated["PowerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + cabletermination_set: List[Annotated["CableTerminationTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + consoleserverports: List[Annotated["ConsoleServerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + poweroutlets: List[Annotated["PowerOutletTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + frontports: List[Annotated["FrontPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + devicebays: List[Annotated["DeviceBayTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + modulebays: List[Annotated["ModuleBayTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + services: List[Annotated["ServiceTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] + inventoryitems: List[Annotated["InventoryItemTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + vdcs: List[Annotated["VirtualDeviceContextTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + @strawberry_django.field + def vc_master_for(self) -> Annotated["VirtualChassisTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None: + return self.vc_master_for if hasattr(self, 'vc_master_for') else None + + @strawberry_django.field + def parent_bay(self) -> Annotated["DeviceBayTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None: + return self.parent_bay if hasattr(self, 'parent_bay') else None + + +@strawberry_django.type( + models.DeviceBay, + fields='__all__', + filters=DeviceBayFilterV1, + pagination=True +) +class DeviceBayTypeV1(ComponentTypeV1): + installed_device: Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + + +@strawberry_django.type( + models.DeviceBayTemplate, + fields='__all__', + filters=DeviceBayTemplateFilterV1, + pagination=True +) +class DeviceBayTemplateTypeV1(ComponentTemplateTypeV1): + pass + + +@strawberry_django.type( + models.InventoryItemTemplate, + exclude=['component_type', 'component_id', 'parent'], + filters=InventoryItemTemplateFilterV1, + pagination=True +) +class InventoryItemTemplateTypeV1(ComponentTemplateTypeV1): + role: Annotated["InventoryItemRoleTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + manufacturer: Annotated["ManufacturerTypeV1", strawberry.lazy('dcim.graphql.types_v1')] + + @strawberry_django.field + def parent(self) -> Annotated["InventoryItemTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None: + return self.parent + + child_items: List[Annotated["InventoryItemTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + component: Annotated[Union[ + Annotated["ConsolePortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["ConsoleServerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["FrontPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["PowerOutletTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["PowerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["RearPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + ], strawberry.union("InventoryItemTemplateComponentTypeV1")] | None + + +@strawberry_django.type( + models.DeviceRole, + fields='__all__', + filters=DeviceRoleFilterV1, + pagination=True +) +class DeviceRoleTypeV1(OrganizationalObjectTypeV1): + parent: Annotated['DeviceRoleTypeV1', strawberry.lazy('dcim.graphql.types_v1')] | None + children: List[Annotated['DeviceRoleTypeV1', strawberry.lazy('dcim.graphql.types_v1')]] + color: str + config_template: Annotated["ConfigTemplateTypeV1", strawberry.lazy('extras.graphql.types_v1')] | None + + virtual_machines: List[Annotated["VirtualMachineTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] + devices: List[Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + +@strawberry_django.type( + models.DeviceType, + fields='__all__', + filters=DeviceTypeFilterV1, + pagination=True +) +class DeviceTypeTypeV1(NetBoxObjectTypeV1): + console_port_template_count: BigInt + console_server_port_template_count: BigInt + power_port_template_count: BigInt + power_outlet_template_count: BigInt + interface_template_count: BigInt + front_port_template_count: BigInt + rear_port_template_count: BigInt + device_bay_template_count: BigInt + module_bay_template_count: BigInt + inventory_item_template_count: BigInt + front_image: strawberry_django.fields.types.DjangoImageType | None + rear_image: strawberry_django.fields.types.DjangoImageType | None + manufacturer: Annotated["ManufacturerTypeV1", strawberry.lazy('dcim.graphql.types_v1')] + default_platform: Annotated["PlatformTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + + frontporttemplates: List[Annotated["FrontPortTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + modulebaytemplates: List[Annotated["ModuleBayTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + instances: List[Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + poweroutlettemplates: List[Annotated["PowerOutletTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + powerporttemplates: List[Annotated["PowerPortTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + inventoryitemtemplates: List[Annotated["InventoryItemTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + rearporttemplates: List[Annotated["RearPortTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + consoleserverporttemplates: List[ + Annotated["ConsoleServerPortTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')] + ] + interfacetemplates: List[Annotated["InterfaceTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + devicebaytemplates: List[Annotated["DeviceBayTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + consoleporttemplates: List[Annotated["ConsolePortTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + +@strawberry_django.type( + models.FrontPort, + fields='__all__', + filters=FrontPortFilterV1, + pagination=True +) +class FrontPortTypeV1(ModularComponentTypeV1, CabledObjectMixinV1): + color: str + rear_port: Annotated["RearPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')] + + +@strawberry_django.type( + models.FrontPortTemplate, + fields='__all__', + filters=FrontPortTemplateFilterV1, + pagination=True +) +class FrontPortTemplateTypeV1(ModularComponentTemplateTypeV1): + color: str + rear_port: Annotated["RearPortTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')] + + +@strawberry_django.type( + models.MACAddress, + exclude=['assigned_object_type', 'assigned_object_id'], + filters=MACAddressFilterV1, + pagination=True +) +class MACAddressTypeV1(NetBoxObjectTypeV1): + mac_address: str + + @strawberry_django.field + def assigned_object(self) -> Annotated[Union[ + Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["VMInterfaceTypeV1", strawberry.lazy('virtualization.graphql.types_v1')], + ], strawberry.union("MACAddressAssignmentTypeV1")] | None: + return self.assigned_object + + +@strawberry_django.type( + models.Interface, + exclude=['_path'], + filters=InterfaceFilterV1, + pagination=True +) +class InterfaceTypeV1(IPAddressesMixinV1, ModularComponentTypeV1, CabledObjectMixinV1, PathEndpointMixinV1): + _name: str + wwn: str | None + parent: Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + bridge: Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + lag: Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + wireless_link: Annotated["WirelessLinkTypeV1", strawberry.lazy('wireless.graphql.types_v1')] | None + untagged_vlan: Annotated["VLANTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + vrf: Annotated["VRFTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + primary_mac_address: Annotated["MACAddressTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + qinq_svlan: Annotated["VLANTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + vlan_translation_policy: Annotated["VLANTranslationPolicyTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + l2vpn_termination: Annotated["L2VPNTerminationTypeV1", strawberry.lazy('vpn.graphql.types_v1')] | None + + vdcs: List[Annotated["VirtualDeviceContextTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + tagged_vlans: List[Annotated["VLANTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] + bridge_interfaces: List[Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + wireless_lans: List[Annotated["WirelessLANTypeV1", strawberry.lazy('wireless.graphql.types_v1')]] + member_interfaces: List[Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + child_interfaces: List[Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + mac_addresses: List[Annotated["MACAddressTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + +@strawberry_django.type( + models.InterfaceTemplate, + fields='__all__', + filters=InterfaceTemplateFilterV1, + pagination=True +) +class InterfaceTemplateTypeV1(ModularComponentTemplateTypeV1): + _name: str + bridge: Annotated["InterfaceTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + + bridge_interfaces: List[Annotated["InterfaceTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + +@strawberry_django.type( + models.InventoryItem, + exclude=['component_type', 'component_id', 'parent'], + filters=InventoryItemFilterV1, + pagination=True +) +class InventoryItemTypeV1(ComponentTypeV1): + role: Annotated["InventoryItemRoleTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + manufacturer: Annotated["ManufacturerTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + + child_items: List[Annotated["InventoryItemTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + @strawberry_django.field + def parent(self) -> Annotated["InventoryItemTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None: + return self.parent + + component: Annotated[Union[ + Annotated["ConsolePortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["ConsoleServerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["FrontPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["PowerOutletTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["PowerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["RearPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + ], strawberry.union("InventoryItemComponentTypeV1")] | None + + +@strawberry_django.type( + models.InventoryItemRole, + fields='__all__', + filters=InventoryItemRoleFilterV1, + pagination=True +) +class InventoryItemRoleTypeV1(OrganizationalObjectTypeV1): + color: str + + inventory_items: List[Annotated["InventoryItemTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + inventory_item_templates: List[Annotated["InventoryItemTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + +@strawberry_django.type( + models.Location, + # fields='__all__', + exclude=['parent'], # bug - temp + filters=LocationFilterV1, + pagination=True +) +class LocationTypeV1(VLANGroupsMixinV1, ImageAttachmentsMixinV1, ContactsMixinV1, OrganizationalObjectTypeV1): + site: Annotated["SiteTypeV1", strawberry.lazy('dcim.graphql.types_v1')] + tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None + parent: Annotated["LocationTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + + powerpanel_set: List[Annotated["PowerPanelTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + cabletermination_set: List[Annotated["CableTerminationTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + racks: List[Annotated["RackTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + devices: List[Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + children: List[Annotated["LocationTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + @strawberry_django.field + def clusters(self) -> List[Annotated["ClusterTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]]: + return self.cluster_set.all() + + @strawberry_django.field + def circuit_terminations(self) -> List[ + Annotated["CircuitTerminationTypeV1", strawberry.lazy('circuits.graphql.types_v1')] + ]: + return self.circuit_terminations.all() + + +@strawberry_django.type( + models.Manufacturer, + fields='__all__', + filters=ManufacturerFilterV1, + pagination=True +) +class ManufacturerTypeV1(OrganizationalObjectTypeV1, ContactsMixinV1): + + platforms: List[Annotated["PlatformTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + device_types: List[Annotated["DeviceTypeTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + inventory_item_templates: List[Annotated["InventoryItemTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + inventory_items: List[Annotated["InventoryItemTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + module_types: List[Annotated["ModuleTypeTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + +@strawberry_django.type( + models.Module, + fields='__all__', + filters=ModuleFilterV1, + pagination=True +) +class ModuleTypeV1(NetBoxObjectTypeV1): + device: Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] + module_bay: Annotated["ModuleBayTypeV1", strawberry.lazy('dcim.graphql.types_v1')] + module_type: Annotated["ModuleTypeTypeV1", strawberry.lazy('dcim.graphql.types_v1')] + + interfaces: List[Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + powerports: List[Annotated["PowerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + consoleserverports: List[Annotated["ConsoleServerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + consoleports: List[Annotated["ConsolePortTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + poweroutlets: List[Annotated["PowerOutletTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + rearports: List[Annotated["RearPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + frontports: List[Annotated["FrontPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + +@strawberry_django.type( + models.ModuleBay, + # fields='__all__', + exclude=['parent'], + filters=ModuleBayFilterV1, + pagination=True +) +class ModuleBayTypeV1(ModularComponentTypeV1): + + installed_module: Annotated["ModuleTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + children: List[Annotated["ModuleBayTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + @strawberry_django.field + def parent(self) -> Annotated["ModuleBayTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None: + return self.parent + + +@strawberry_django.type( + models.ModuleBayTemplate, + fields='__all__', + filters=ModuleBayTemplateFilterV1, + pagination=True +) +class ModuleBayTemplateTypeV1(ModularComponentTemplateTypeV1): + pass + + +@strawberry_django.type( + models.ModuleTypeProfile, + fields='__all__', + filters=ModuleTypeProfileFilterV1, + pagination=True +) +class ModuleTypeProfileTypeV1(NetBoxObjectTypeV1): + module_types: List[Annotated["ModuleTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + +@strawberry_django.type( + models.ModuleType, + fields='__all__', + filters=ModuleTypeFilterV1, + pagination=True +) +class ModuleTypeTypeV1(NetBoxObjectTypeV1): + profile: Annotated["ModuleTypeProfileTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + manufacturer: Annotated["ManufacturerTypeV1", strawberry.lazy('dcim.graphql.types_v1')] + + frontporttemplates: List[Annotated["FrontPortTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + consoleserverporttemplates: List[ + Annotated["ConsoleServerPortTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')] + ] + interfacetemplates: List[Annotated["InterfaceTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + powerporttemplates: List[Annotated["PowerPortTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + poweroutlettemplates: List[Annotated["PowerOutletTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + rearporttemplates: List[Annotated["RearPortTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + instances: List[Annotated["ModuleTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + consoleporttemplates: List[Annotated["ConsolePortTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + +@strawberry_django.type( + models.Platform, + fields='__all__', + filters=PlatformFilterV1, + pagination=True +) +class PlatformTypeV1(OrganizationalObjectTypeV1): + parent: Annotated['PlatformTypeV1', strawberry.lazy('dcim.graphql.types_v1')] | None + children: List[Annotated['PlatformTypeV1', strawberry.lazy('dcim.graphql.types_v1')]] + manufacturer: Annotated["ManufacturerTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + config_template: Annotated["ConfigTemplateTypeV1", strawberry.lazy('extras.graphql.types_v1')] | None + + virtual_machines: List[Annotated["VirtualMachineTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] + devices: List[Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + +@strawberry_django.type( + models.PowerFeed, + exclude=['_path'], + filters=PowerFeedFilterV1, + pagination=True +) +class PowerFeedTypeV1(NetBoxObjectTypeV1, CabledObjectMixinV1, PathEndpointMixinV1): + power_panel: Annotated["PowerPanelTypeV1", strawberry.lazy('dcim.graphql.types_v1')] + rack: Annotated["RackTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None + + +@strawberry_django.type( + models.PowerOutlet, + exclude=['_path'], + filters=PowerOutletFilterV1, + pagination=True +) +class PowerOutletTypeV1(ModularComponentTypeV1, CabledObjectMixinV1, PathEndpointMixinV1): + power_port: Annotated["PowerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + color: str + + +@strawberry_django.type( + models.PowerOutletTemplate, + fields='__all__', + filters=PowerOutletTemplateFilterV1, + pagination=True +) +class PowerOutletTemplateTypeV1(ModularComponentTemplateTypeV1): + power_port: Annotated["PowerPortTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + + +@strawberry_django.type( + models.PowerPanel, + fields='__all__', + filters=PowerPanelFilterV1, + pagination=True +) +class PowerPanelTypeV1(NetBoxObjectTypeV1, ContactsMixinV1): + site: Annotated["SiteTypeV1", strawberry.lazy('dcim.graphql.types_v1')] + location: Annotated["LocationTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + + powerfeeds: List[Annotated["PowerFeedTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + +@strawberry_django.type( + models.PowerPort, + exclude=['_path'], + filters=PowerPortFilterV1, + pagination=True +) +class PowerPortTypeV1(ModularComponentTypeV1, CabledObjectMixinV1, PathEndpointMixinV1): + + poweroutlets: List[Annotated["PowerOutletTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + +@strawberry_django.type( + models.PowerPortTemplate, + fields='__all__', + filters=PowerPortTemplateFilterV1, + pagination=True +) +class PowerPortTemplateTypeV1(ModularComponentTemplateTypeV1): + poweroutlet_templates: List[Annotated["PowerOutletTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + +@strawberry_django.type( + models.RackType, + fields='__all__', + filters=RackTypeFilterV1, + pagination=True +) +class RackTypeTypeV1(NetBoxObjectTypeV1): + manufacturer: Annotated["ManufacturerTypeV1", strawberry.lazy('dcim.graphql.types_v1')] + + +@strawberry_django.type( + models.Rack, + fields='__all__', + filters=RackFilterV1, + pagination=True +) +class RackTypeV1(VLANGroupsMixinV1, ImageAttachmentsMixinV1, ContactsMixinV1, NetBoxObjectTypeV1): + site: Annotated["SiteTypeV1", strawberry.lazy('dcim.graphql.types_v1')] + location: Annotated["LocationTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None + role: Annotated["RackRoleTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + + rack_type: Annotated["RackTypeTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + reservations: List[Annotated["RackReservationTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + devices: List[Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + powerfeeds: List[Annotated["PowerFeedTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + cabletermination_set: List[Annotated["CableTerminationTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + +@strawberry_django.type( + models.RackReservation, + fields='__all__', + filters=RackReservationFilterV1, + pagination=True +) +class RackReservationTypeV1(NetBoxObjectTypeV1): + units: List[int] + rack: Annotated["RackTypeV1", strawberry.lazy('dcim.graphql.types_v1')] + tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None + user: Annotated["UserTypeV1", strawberry.lazy('users.graphql.types_v1')] + + +@strawberry_django.type( + models.RackRole, + fields='__all__', + filters=RackRoleFilterV1, + pagination=True +) +class RackRoleTypeV1(OrganizationalObjectTypeV1): + color: str + + racks: List[Annotated["RackTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + +@strawberry_django.type( + models.RearPort, + fields='__all__', + filters=RearPortFilterV1, + pagination=True +) +class RearPortTypeV1(ModularComponentTypeV1, CabledObjectMixinV1): + color: str + + frontports: List[Annotated["FrontPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + +@strawberry_django.type( + models.RearPortTemplate, + fields='__all__', + filters=RearPortTemplateFilterV1, + pagination=True +) +class RearPortTemplateTypeV1(ModularComponentTemplateTypeV1): + color: str + + frontport_templates: List[Annotated["FrontPortTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + +@strawberry_django.type( + models.Region, + exclude=['parent'], + filters=RegionFilterV1, + pagination=True +) +class RegionTypeV1(VLANGroupsMixinV1, ContactsMixinV1, OrganizationalObjectTypeV1): + + sites: List[Annotated["SiteTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + children: List[Annotated["RegionTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + @strawberry_django.field + def parent(self) -> Annotated["RegionTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None: + return self.parent + + @strawberry_django.field + def clusters(self) -> List[Annotated["ClusterTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]]: + return self.cluster_set.all() + + @strawberry_django.field + def circuit_terminations(self) -> List[ + Annotated["CircuitTerminationTypeV1", strawberry.lazy('circuits.graphql.types_v1')] + ]: + return self.circuit_terminations.all() + + +@strawberry_django.type( + models.Site, + fields='__all__', + filters=SiteFilterV1, + pagination=True +) +class SiteTypeV1(VLANGroupsMixinV1, ImageAttachmentsMixinV1, ContactsMixinV1, NetBoxObjectTypeV1): + time_zone: str | None + region: Annotated["RegionTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + group: Annotated["SiteGroupTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None + + prefixes: List[Annotated["PrefixTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] + virtual_machines: List[Annotated["VirtualMachineTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] + racks: List[Annotated["RackTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + cabletermination_set: List[Annotated["CableTerminationTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + powerpanel_set: List[Annotated["PowerPanelTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + devices: List[Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + locations: List[Annotated["LocationTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + asns: List[Annotated["ASNTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] + circuit_terminations: List[Annotated["CircuitTerminationTypeV1", strawberry.lazy('circuits.graphql.types_v1')]] + clusters: List[Annotated["ClusterTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] + vlans: List[Annotated["VLANTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] + + @strawberry_django.field + def clusters(self) -> List[Annotated["ClusterTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]]: + return self.cluster_set.all() + + @strawberry_django.field + def circuit_terminations(self) -> List[ + Annotated["CircuitTerminationTypeV1", strawberry.lazy('circuits.graphql.types_v1')] + ]: + return self.circuit_terminations.all() + + +@strawberry_django.type( + models.SiteGroup, + exclude=['parent'], # bug - temp + filters=SiteGroupFilterV1, + pagination=True +) +class SiteGroupTypeV1(VLANGroupsMixinV1, ContactsMixinV1, OrganizationalObjectTypeV1): + + sites: List[Annotated["SiteTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + children: List[Annotated["SiteGroupTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + @strawberry_django.field + def parent(self) -> Annotated["SiteGroupTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None: + return self.parent + + @strawberry_django.field + def clusters(self) -> List[Annotated["ClusterTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]]: + return self.cluster_set.all() + + @strawberry_django.field + def circuit_terminations(self) -> List[ + Annotated["CircuitTerminationTypeV1", strawberry.lazy('circuits.graphql.types_v1')] + ]: + return self.circuit_terminations.all() + + +@strawberry_django.type( + models.VirtualChassis, + fields='__all__', + filters=VirtualChassisFilterV1, + pagination=True +) +class VirtualChassisTypeV1(NetBoxObjectTypeV1): + member_count: BigInt + master: Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + + members: List[Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + +@strawberry_django.type( + models.VirtualDeviceContext, + fields='__all__', + filters=VirtualDeviceContextFilterV1, + pagination=True +) +class VirtualDeviceContextTypeV1(NetBoxObjectTypeV1): + device: Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + primary_ip4: Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + primary_ip6: Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None + + interfaces: List[Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] diff --git a/netbox/extras/graphql/filter_mixins_v1.py b/netbox/extras/graphql/filter_mixins_v1.py new file mode 100644 index 000000000..48611cc83 --- /dev/null +++ b/netbox/extras/graphql/filter_mixins_v1.py @@ -0,0 +1,52 @@ +from dataclasses import dataclass +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django +from strawberry_django import FilterLookup + +from core.graphql.filter_mixins_v1 import BaseFilterMixinV1 + +if TYPE_CHECKING: + from netbox.graphql.filter_lookups import JSONFilter + from .filters_v1 import * + +__all__ = ( + 'CustomFieldsFilterMixinV1', + 'JournalEntriesFilterMixinV1', + 'TagsFilterMixinV1', + 'ConfigContextFilterMixinV1', + 'TagBaseFilterMixinV1', +) + + +@dataclass +class CustomFieldsFilterMixinV1(BaseFilterMixinV1): + custom_field_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + + +@dataclass +class JournalEntriesFilterMixinV1(BaseFilterMixinV1): + journal_entries: Annotated['JournalEntryFilterV1', strawberry.lazy('extras.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@dataclass +class TagsFilterMixinV1(BaseFilterMixinV1): + tags: Annotated['TagFilter', strawberry.lazy('extras.graphql.filters')] | None = strawberry_django.filter_field() + + +@dataclass +class ConfigContextFilterMixinV1(BaseFilterMixinV1): + local_context_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + + +@dataclass +class TagBaseFilterMixinV1(BaseFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/extras/graphql/filters_v1.py b/netbox/extras/graphql/filters_v1.py new file mode 100644 index 000000000..b903fee34 --- /dev/null +++ b/netbox/extras/graphql/filters_v1.py @@ -0,0 +1,357 @@ +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django +from strawberry.scalars import ID +from strawberry_django import FilterLookup + +from core.graphql.filter_mixins_v1 import BaseObjectTypeFilterMixinV1, ChangeLogFilterMixinV1 +from extras import models +from extras.graphql.filter_mixins_v1 import TagBaseFilterMixinV1, CustomFieldsFilterMixinV1, TagsFilterMixinV1 +from netbox.graphql.filter_mixins_v1 import PrimaryModelFilterMixinV1, SyncedDataFilterMixinV1 + +if TYPE_CHECKING: + from core.graphql.filters_v1 import ContentTypeFilterV1 + from dcim.graphql.filters_v1 import ( + DeviceRoleFilterV1, + DeviceTypeFilterV1, + LocationFilterV1, + PlatformFilterV1, + RegionFilterV1, + SiteFilterV1, + SiteGroupFilterV1, + ) + from tenancy.graphql.filters_v1 import TenantFilterV1, TenantGroupFilterV1 + from netbox.graphql.enums import ColorEnum + from netbox.graphql.filter_lookups import FloatLookup, IntegerLookup, JSONFilter, StringArrayLookup, TreeNodeFilter + from virtualization.graphql.filters_v1 import ClusterFilterV1, ClusterGroupFilterV1, ClusterTypeFilterV1 + from .enums import * + +__all__ = ( + 'ConfigContextFilterV1', + 'ConfigContextProfileFilterV1', + 'ConfigTemplateFilterV1', + 'CustomFieldFilterV1', + 'CustomFieldChoiceSetFilterV1', + 'CustomLinkFilterV1', + 'EventRuleFilterV1', + 'ExportTemplateFilterV1', + 'ImageAttachmentFilterV1', + 'JournalEntryFilterV1', + 'NotificationGroupFilterV1', + 'SavedFilterFilterV1', + 'TableConfigFilterV1', + 'TagFilterV1', + 'WebhookFilterV1', +) + + +@strawberry_django.filter_type(models.ConfigContext, lookups=True) +class ConfigContextFilterV1(BaseObjectTypeFilterMixinV1, SyncedDataFilterMixinV1, ChangeLogFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + description: FilterLookup[str] | None = strawberry_django.filter_field() + is_active: FilterLookup[bool] | None = strawberry_django.filter_field() + regions: Annotated['RegionFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + region_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + site_groups: Annotated['SiteGroupFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + site_group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + sites: Annotated['SiteFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + locations: Annotated['LocationFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + device_types: Annotated['DeviceTypeFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + roles: Annotated['DeviceRoleFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + platforms: Annotated['PlatformFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + cluster_types: Annotated['ClusterTypeFilterV1', strawberry.lazy('virtualization.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + cluster_groups: Annotated['ClusterGroupFilterV1', strawberry.lazy('virtualization.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + clusters: Annotated['ClusterFilterV1', strawberry.lazy('virtualization.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + tenant_groups: Annotated['TenantGroupFilterV1', strawberry.lazy('tenancy.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + tenant_group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + tenants: Annotated['TenantFilterV1', strawberry.lazy('tenancy.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + tags: Annotated['TagFilterV1', strawberry.lazy('extras.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.ConfigContextProfile, lookups=True) +class ConfigContextProfileFilterV1(SyncedDataFilterMixinV1, PrimaryModelFilterMixinV1): + name: FilterLookup[str] = strawberry_django.filter_field() + description: FilterLookup[str] = strawberry_django.filter_field() + tags: Annotated['TagFilterV1', strawberry.lazy('extras.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.ConfigTemplate, lookups=True) +class ConfigTemplateFilterV1(BaseObjectTypeFilterMixinV1, SyncedDataFilterMixinV1, ChangeLogFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + template_code: FilterLookup[str] | None = strawberry_django.filter_field() + environment_params: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + mime_type: FilterLookup[str] | None = strawberry_django.filter_field() + file_name: FilterLookup[str] | None = strawberry_django.filter_field() + file_extension: FilterLookup[str] | None = strawberry_django.filter_field() + as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.CustomField, lookups=True) +class CustomFieldFilterV1(BaseObjectTypeFilterMixinV1, ChangeLogFilterMixinV1): + type: Annotated['CustomFieldTypeEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + object_types: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + related_object_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + name: FilterLookup[str] | None = strawberry_django.filter_field() + label: FilterLookup[str] | None = strawberry_django.filter_field() + group_name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + required: FilterLookup[bool] | None = strawberry_django.filter_field() + unique: FilterLookup[bool] | None = strawberry_django.filter_field() + search_weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + filter_logic: Annotated['CustomFieldFilterLogicEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + default: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + related_object_filter: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + validation_minimum: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + validation_maximum: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + validation_regex: FilterLookup[str] | None = strawberry_django.filter_field() + choice_set: Annotated['CustomFieldChoiceSetFilterV1', strawberry.lazy('extras.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + choice_set_id: ID | None = strawberry_django.filter_field() + ui_visible: Annotated['CustomFieldUIVisibleEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + ui_editable: Annotated['CustomFieldUIEditableEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + is_cloneable: FilterLookup[bool] | None = strawberry_django.filter_field() + comments: FilterLookup[str] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.CustomFieldChoiceSet, lookups=True) +class CustomFieldChoiceSetFilterV1(BaseObjectTypeFilterMixinV1, ChangeLogFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + base_choices: Annotated['CustomFieldChoiceSetBaseEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + extra_choices: Annotated['StringArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + order_alphabetically: FilterLookup[bool] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.CustomLink, lookups=True) +class CustomLinkFilterV1(BaseObjectTypeFilterMixinV1, ChangeLogFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + enabled: FilterLookup[bool] | None = strawberry_django.filter_field() + link_text: FilterLookup[str] | None = strawberry_django.filter_field() + link_url: FilterLookup[str] | None = strawberry_django.filter_field() + weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + group_name: FilterLookup[str] | None = strawberry_django.filter_field() + button_class: Annotated['CustomLinkButtonClassEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + new_window: FilterLookup[bool] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.ExportTemplate, lookups=True) +class ExportTemplateFilterV1(BaseObjectTypeFilterMixinV1, SyncedDataFilterMixinV1, ChangeLogFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + template_code: FilterLookup[str] | None = strawberry_django.filter_field() + environment_params: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + mime_type: FilterLookup[str] | None = strawberry_django.filter_field() + file_name: FilterLookup[str] | None = strawberry_django.filter_field() + file_extension: FilterLookup[str] | None = strawberry_django.filter_field() + as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.ImageAttachment, lookups=True) +class ImageAttachmentFilterV1(BaseObjectTypeFilterMixinV1, ChangeLogFilterMixinV1): + object_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + object_id: ID | None = strawberry_django.filter_field() + image_height: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + image_width: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + name: FilterLookup[str] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.JournalEntry, lookups=True) +class JournalEntryFilterV1( + BaseObjectTypeFilterMixinV1, CustomFieldsFilterMixinV1, TagsFilterMixinV1, ChangeLogFilterMixinV1 +): + assigned_object_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + assigned_object_type_id: ID | None = strawberry_django.filter_field() + assigned_object_id: ID | None = strawberry_django.filter_field() + created_by: Annotated['UserFilterV1', strawberry.lazy('users.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + kind: Annotated['JournalEntryKindEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + comments: FilterLookup[str] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.NotificationGroup, lookups=True) +class NotificationGroupFilterV1(BaseObjectTypeFilterMixinV1, ChangeLogFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + groups: Annotated['GroupFilterV1', strawberry.lazy('users.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + users: Annotated['UserFilterV1', strawberry.lazy('users.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.SavedFilter, lookups=True) +class SavedFilterFilterV1(BaseObjectTypeFilterMixinV1, ChangeLogFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + user: Annotated['UserFilterV1', strawberry.lazy('users.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + user_id: ID | None = strawberry_django.filter_field() + weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + enabled: FilterLookup[bool] | None = strawberry_django.filter_field() + shared: FilterLookup[bool] | None = strawberry_django.filter_field() + parameters: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.TableConfig, lookups=True) +class TableConfigFilterV1(BaseObjectTypeFilterMixinV1, ChangeLogFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + user: Annotated['UserFilterV1', strawberry.lazy('users.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + user_id: ID | None = strawberry_django.filter_field() + weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + enabled: FilterLookup[bool] | None = strawberry_django.filter_field() + shared: FilterLookup[bool] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.Tag, lookups=True) +class TagFilterV1(BaseObjectTypeFilterMixinV1, ChangeLogFilterMixinV1, TagBaseFilterMixinV1): + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.Webhook, lookups=True) +class WebhookFilterV1( + BaseObjectTypeFilterMixinV1, CustomFieldsFilterMixinV1, TagsFilterMixinV1, ChangeLogFilterMixinV1 +): + name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + payload_url: FilterLookup[str] | None = strawberry_django.filter_field() + http_method: Annotated['WebhookHttpMethodEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + http_content_type: FilterLookup[str] | None = strawberry_django.filter_field() + additional_headers: FilterLookup[str] | None = strawberry_django.filter_field() + body_template: FilterLookup[str] | None = strawberry_django.filter_field() + secret: FilterLookup[str] | None = strawberry_django.filter_field() + ssl_verification: FilterLookup[bool] | None = strawberry_django.filter_field() + ca_file_path: FilterLookup[str] | None = strawberry_django.filter_field() + events: Annotated['EventRuleFilterV1', strawberry.lazy('extras.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.EventRule, lookups=True) +class EventRuleFilterV1( + BaseObjectTypeFilterMixinV1, CustomFieldsFilterMixinV1, TagsFilterMixinV1, ChangeLogFilterMixinV1 +): + name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + event_types: Annotated['StringArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + enabled: FilterLookup[bool] | None = strawberry_django.filter_field() + conditions: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + action_type: Annotated['EventRuleActionEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + action_object_type: FilterLookup[str] | None = strawberry_django.filter_field() + action_object_type_id: ID | None = strawberry_django.filter_field() + action_object_id: ID | None = strawberry_django.filter_field() + action_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + comments: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/extras/graphql/mixins_v1.py b/netbox/extras/graphql/mixins_v1.py new file mode 100644 index 000000000..4f56549d7 --- /dev/null +++ b/netbox/extras/graphql/mixins_v1.py @@ -0,0 +1,62 @@ +from typing import TYPE_CHECKING, Annotated, List + +import strawberry +import strawberry_django +from strawberry.types import Info + +__all__ = ( + 'ConfigContextMixinV1', + 'ContactsMixinV1', + 'CustomFieldsMixinV1', + 'ImageAttachmentsMixinV1', + 'JournalEntriesMixinV1', + 'TagsMixinV1', +) + +if TYPE_CHECKING: + from .types_v1 import ImageAttachmentTypeV1, JournalEntryTypeV1, TagTypeV1 + from tenancy.graphql.types_v1 import ContactAssignmentTypeV1 + + +@strawberry.type +class ConfigContextMixinV1: + + @strawberry_django.field + def config_context(self) -> strawberry.scalars.JSON: + return self.get_config_context() + + +@strawberry.type +class CustomFieldsMixinV1: + + @strawberry_django.field + def custom_fields(self) -> strawberry.scalars.JSON: + return self.custom_field_data + + +@strawberry.type +class ImageAttachmentsMixinV1: + + @strawberry_django.field + def image_attachments(self, info: Info) -> List[Annotated['ImageAttachmentTypeV1', strawberry.lazy('.types_v1')]]: + return self.images.restrict(info.context.request.user, 'view') + + +@strawberry.type +class JournalEntriesMixinV1: + + @strawberry_django.field + def journal_entries(self, info: Info) -> List[Annotated['JournalEntryTypeV1', strawberry.lazy('.types_v1')]]: + return self.journal_entries.all() + + +@strawberry.type +class TagsMixinV1: + + tags: List[Annotated['TagTypeV1', strawberry.lazy('.types_v1')]] + + +@strawberry.type +class ContactsMixinV1: + + contacts: List[Annotated['ContactAssignmentTypeV1', strawberry.lazy('tenancy.graphql.types_v1')]] diff --git a/netbox/extras/graphql/schema_v1.py b/netbox/extras/graphql/schema_v1.py new file mode 100644 index 000000000..3a2757105 --- /dev/null +++ b/netbox/extras/graphql/schema_v1.py @@ -0,0 +1,60 @@ +from typing import List + +import strawberry +import strawberry_django + +from .types_v1 import * + + +@strawberry.type(name="Query") +class ExtrasQueryV1: + config_context: ConfigContextTypeV1 = strawberry_django.field() + config_context_list: List[ConfigContextTypeV1] = strawberry_django.field() + + config_context_profile: ConfigContextProfileTypeV1 = strawberry_django.field() + config_context_profile_list: List[ConfigContextProfileTypeV1] = strawberry_django.field() + + config_template: ConfigTemplateTypeV1 = strawberry_django.field() + config_template_list: List[ConfigTemplateTypeV1] = strawberry_django.field() + + custom_field: CustomFieldTypeV1 = strawberry_django.field() + custom_field_list: List[CustomFieldTypeV1] = strawberry_django.field() + + custom_field_choice_set: CustomFieldChoiceSetTypeV1 = strawberry_django.field() + custom_field_choice_set_list: List[CustomFieldChoiceSetTypeV1] = strawberry_django.field() + + custom_link: CustomLinkTypeV1 = strawberry_django.field() + custom_link_list: List[CustomLinkTypeV1] = strawberry_django.field() + + export_template: ExportTemplateTypeV1 = strawberry_django.field() + export_template_list: List[ExportTemplateTypeV1] = strawberry_django.field() + + image_attachment: ImageAttachmentTypeV1 = strawberry_django.field() + image_attachment_list: List[ImageAttachmentTypeV1] = strawberry_django.field() + + saved_filter: SavedFilterTypeV1 = strawberry_django.field() + saved_filter_list: List[SavedFilterTypeV1] = strawberry_django.field() + + table_config: TableConfigTypeV1 = strawberry_django.field() + table_config_list: List[TableConfigTypeV1] = strawberry_django.field() + + journal_entry: JournalEntryTypeV1 = strawberry_django.field() + journal_entry_list: List[JournalEntryTypeV1] = strawberry_django.field() + + notification: NotificationTypeV1 = strawberry_django.field() + notification_list: List[NotificationTypeV1] = strawberry_django.field() + + notification_group: NotificationGroupTypeV1 = strawberry_django.field() + notification_group_list: List[NotificationGroupTypeV1] = strawberry_django.field() + + subscription: SubscriptionTypeV1 = strawberry_django.field() + subscription_list: List[SubscriptionTypeV1] = strawberry_django.field() + + tag: TagTypeV1 = strawberry_django.field() + tag_list: List[TagTypeV1] = strawberry_django.field() + + webhook: WebhookTypeV1 = strawberry_django.field() + webhook_list: List[WebhookTypeV1] = strawberry_django.field() + + event_rule: EventRuleTypeV1 = strawberry_django.field() + event_rule_list: List[EventRuleTypeV1] = strawberry_django.field() diff --git a/netbox/extras/graphql/types_v1.py b/netbox/extras/graphql/types_v1.py new file mode 100644 index 000000000..d51b57e88 --- /dev/null +++ b/netbox/extras/graphql/types_v1.py @@ -0,0 +1,239 @@ +from typing import Annotated, List, TYPE_CHECKING + +import strawberry +import strawberry_django + +from core.graphql.mixins_v1 import SyncedDataMixinV1 +from extras import models +from extras.graphql.mixins_v1 import CustomFieldsMixinV1, TagsMixinV1 +from netbox.graphql.types_v1 import ( + BaseObjectTypeV1, ContentTypeTypeV1, NetBoxObjectTypeV1, ObjectTypeV1, OrganizationalObjectTypeV1 +) +from .filters_v1 import * + +if TYPE_CHECKING: + from dcim.graphql.types_v1 import ( + DeviceRoleTypeV1, + DeviceTypeV1, + DeviceTypeTypeV1, + LocationTypeV1, + PlatformTypeV1, + RegionTypeV1, + SiteGroupTypeV1, + SiteTypeV1, + ) + from tenancy.graphql.types_v1 import TenantGroupTypeV1, TenantTypeV1 + from users.graphql.types_v1 import GroupTypeV1, UserTypeV1 + from virtualization.graphql.types_v1 import ( + ClusterGroupTypeV1, ClusterTypeV1, ClusterTypeTypeV1, VirtualMachineTypeV1 + ) + +__all__ = ( + 'ConfigContextProfileTypeV1', + 'ConfigContextTypeV1', + 'ConfigTemplateTypeV1', + 'CustomFieldChoiceSetTypeV1', + 'CustomFieldTypeV1', + 'CustomLinkTypeV1', + 'EventRuleTypeV1', + 'ExportTemplateTypeV1', + 'ImageAttachmentTypeV1', + 'JournalEntryTypeV1', + 'NotificationGroupTypeV1', + 'NotificationTypeV1', + 'SavedFilterTypeV1', + 'SubscriptionTypeV1', + 'TableConfigTypeV1', + 'TagTypeV1', + 'WebhookTypeV1', +) + + +@strawberry_django.type( + models.ConfigContextProfile, + fields='__all__', + filters=ConfigContextProfileFilterV1, + pagination=True +) +class ConfigContextProfileTypeV1(SyncedDataMixinV1, NetBoxObjectTypeV1): + pass + + +@strawberry_django.type( + models.ConfigContext, + fields='__all__', + filters=ConfigContextFilterV1, + pagination=True +) +class ConfigContextTypeV1(SyncedDataMixinV1, ObjectTypeV1): + profile: ConfigContextProfileTypeV1 | None + roles: List[Annotated["DeviceRoleTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + device_types: List[Annotated["DeviceTypeTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + tags: List[Annotated["TagTypeV1", strawberry.lazy('extras.graphql.types_v1')]] + platforms: List[Annotated["PlatformTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + regions: List[Annotated["RegionTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + cluster_groups: List[Annotated["ClusterGroupTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] + tenant_groups: List[Annotated["TenantGroupTypeV1", strawberry.lazy('tenancy.graphql.types_v1')]] + cluster_types: List[Annotated["ClusterTypeTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] + clusters: List[Annotated["ClusterTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] + locations: List[Annotated["LocationTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + sites: List[Annotated["SiteTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + tenants: List[Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')]] + site_groups: List[Annotated["SiteGroupTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + +@strawberry_django.type( + models.ConfigTemplate, + fields='__all__', + filters=ConfigTemplateFilterV1, + pagination=True +) +class ConfigTemplateTypeV1(SyncedDataMixinV1, TagsMixinV1, ObjectTypeV1): + virtualmachines: List[Annotated["VirtualMachineTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] + devices: List[Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + platforms: List[Annotated["PlatformTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + device_roles: List[Annotated["DeviceRoleTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + +@strawberry_django.type( + models.CustomField, + fields='__all__', + filters=CustomFieldFilterV1, + pagination=True +) +class CustomFieldTypeV1(ObjectTypeV1): + related_object_type: Annotated["ContentTypeTypeV1", strawberry.lazy('netbox.graphql.types_v1')] | None + choice_set: Annotated["CustomFieldChoiceSetTypeV1", strawberry.lazy('extras.graphql.types_v1')] | None + + +@strawberry_django.type( + models.CustomFieldChoiceSet, + exclude=['extra_choices'], + filters=CustomFieldChoiceSetFilterV1, + pagination=True +) +class CustomFieldChoiceSetTypeV1(ObjectTypeV1): + + choices_for: List[Annotated["CustomFieldTypeV1", strawberry.lazy('extras.graphql.types_v1')]] + extra_choices: List[List[str]] | None + + +@strawberry_django.type( + models.CustomLink, + fields='__all__', + filters=CustomLinkFilterV1, + pagination=True +) +class CustomLinkTypeV1(ObjectTypeV1): + pass + + +@strawberry_django.type( + models.ExportTemplate, + fields='__all__', + filters=ExportTemplateFilterV1, + pagination=True +) +class ExportTemplateTypeV1(SyncedDataMixinV1, ObjectTypeV1): + pass + + +@strawberry_django.type( + models.ImageAttachment, + fields='__all__', + filters=ImageAttachmentFilterV1, + pagination=True +) +class ImageAttachmentTypeV1(BaseObjectTypeV1): + object_type: Annotated["ContentTypeTypeV1", strawberry.lazy('netbox.graphql.types_v1')] | None + + +@strawberry_django.type( + models.JournalEntry, + fields='__all__', + filters=JournalEntryFilterV1, + pagination=True +) +class JournalEntryTypeV1(CustomFieldsMixinV1, TagsMixinV1, ObjectTypeV1): + assigned_object_type: Annotated["ContentTypeTypeV1", strawberry.lazy('netbox.graphql.types_v1')] | None + created_by: Annotated["UserTypeV1", strawberry.lazy('users.graphql.types_v1')] | None + + +@strawberry_django.type( + models.Notification, + # filters=NotificationFilter + pagination=True +) +class NotificationTypeV1(ObjectTypeV1): + user: Annotated["UserTypeV1", strawberry.lazy('users.graphql.types_v1')] | None + + +@strawberry_django.type( + models.NotificationGroup, + filters=NotificationGroupFilterV1, + pagination=True +) +class NotificationGroupTypeV1(ObjectTypeV1): + users: List[Annotated["UserTypeV1", strawberry.lazy('users.graphql.types_v1')]] + groups: List[Annotated["GroupTypeV1", strawberry.lazy('users.graphql.types_v1')]] + + +@strawberry_django.type( + models.SavedFilter, + exclude=['content_types',], + filters=SavedFilterFilterV1, + pagination=True +) +class SavedFilterTypeV1(ObjectTypeV1): + user: Annotated["UserTypeV1", strawberry.lazy('users.graphql.types_v1')] | None + + +@strawberry_django.type( + models.Subscription, + # filters=NotificationFilter + pagination=True +) +class SubscriptionTypeV1(ObjectTypeV1): + user: Annotated["UserTypeV1", strawberry.lazy('users.graphql.types_v1')] | None + + +@strawberry_django.type( + models.TableConfig, + fields='__all__', + filters=TableConfigFilterV1, + pagination=True +) +class TableConfigTypeV1(ObjectTypeV1): + user: Annotated["UserTypeV1", strawberry.lazy('users.graphql.types_v1')] | None + + +@strawberry_django.type( + models.Tag, + exclude=['extras_taggeditem_items', ], + filters=TagFilterV1, + pagination=True +) +class TagTypeV1(ObjectTypeV1): + color: str + + object_types: List[ContentTypeTypeV1] + + +@strawberry_django.type( + models.Webhook, + exclude=['content_types',], + filters=WebhookFilterV1, + pagination=True +) +class WebhookTypeV1(OrganizationalObjectTypeV1): + pass + + +@strawberry_django.type( + models.EventRule, + exclude=['content_types',], + filters=EventRuleFilterV1, + pagination=True +) +class EventRuleTypeV1(OrganizationalObjectTypeV1): + action_object_type: Annotated["ContentTypeTypeV1", strawberry.lazy('netbox.graphql.types_v1')] | None diff --git a/netbox/ipam/graphql/filter_mixins_v1.py b/netbox/ipam/graphql/filter_mixins_v1.py new file mode 100644 index 000000000..0ba314d5b --- /dev/null +++ b/netbox/ipam/graphql/filter_mixins_v1.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django + +from core.graphql.filter_mixins_v1 import BaseFilterMixinV1 + +if TYPE_CHECKING: + from netbox.graphql.filter_lookups import IntegerLookup + from .enums import * + +__all__ = ( + 'ServiceBaseFilterMixinV1', +) + + +@dataclass +class ServiceBaseFilterMixinV1(BaseFilterMixinV1): + protocol: Annotated['ServiceProtocolEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + ports: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) diff --git a/netbox/ipam/graphql/filters_v1.py b/netbox/ipam/graphql/filters_v1.py new file mode 100644 index 000000000..7e2171e76 --- /dev/null +++ b/netbox/ipam/graphql/filters_v1.py @@ -0,0 +1,392 @@ +from datetime import date +from typing import Annotated, TYPE_CHECKING + +import netaddr +import strawberry +import strawberry_django +from django.db.models import Q +from netaddr.core import AddrFormatError +from strawberry.scalars import ID +from strawberry_django import FilterLookup, DateFilterLookup + +from core.graphql.filter_mixins_v1 import BaseObjectTypeFilterMixinV1, ChangeLogFilterMixinV1 +from dcim.graphql.filter_mixins_v1 import ScopedFilterMixinV1 +from dcim.models import Device +from ipam import models +from ipam.graphql.filter_mixins_v1 import ServiceBaseFilterMixinV1 +from netbox.graphql.filter_mixins_v1 import ( + NetBoxModelFilterMixinV1, OrganizationalModelFilterMixinV1, PrimaryModelFilterMixinV1 +) +from tenancy.graphql.filter_mixins_v1 import ContactFilterMixinV1, TenancyFilterMixinV1 +from virtualization.models import VMInterface + +if TYPE_CHECKING: + from netbox.graphql.filter_lookups import IntegerLookup, IntegerRangeArrayLookup + from circuits.graphql.filters_v1 import ProviderFilterV1 + from core.graphql.filters_v1 import ContentTypeFilterV1 + from dcim.graphql.filters_v1 import SiteFilterV1 + from vpn.graphql.filters_v1 import L2VPNFilterV1 + from .enums import * + +__all__ = ( + 'ASNFilterV1', + 'ASNRangeFilterV1', + 'AggregateFilterV1', + 'FHRPGroupFilterV1', + 'FHRPGroupAssignmentFilterV1', + 'IPAddressFilterV1', + 'IPRangeFilterV1', + 'PrefixFilterV1', + 'RIRFilterV1', + 'RoleFilterV1', + 'RouteTargetFilterV1', + 'ServiceFilterV1', + 'ServiceTemplateFilterV1', + 'VLANFilterV1', + 'VLANGroupFilterV1', + 'VLANTranslationPolicyFilterV1', + 'VLANTranslationRuleFilterV1', + 'VRFFilterV1', +) + + +@strawberry_django.filter_type(models.ASN, lookups=True) +class ASNFilterV1(TenancyFilterMixinV1, PrimaryModelFilterMixinV1): + rir: Annotated['RIRFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = strawberry_django.filter_field() + rir_id: ID | None = strawberry_django.filter_field() + asn: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + sites: ( + Annotated['SiteFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None + ) = strawberry_django.filter_field() + providers: ( + Annotated['ProviderFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None + ) = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.ASNRange, lookups=True) +class ASNRangeFilterV1(TenancyFilterMixinV1, OrganizationalModelFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() + rir: Annotated['RIRFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = strawberry_django.filter_field() + rir_id: ID | None = strawberry_django.filter_field() + start: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + end: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.Aggregate, lookups=True) +class AggregateFilterV1(ContactFilterMixinV1, TenancyFilterMixinV1, PrimaryModelFilterMixinV1): + prefix: Annotated['PrefixFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + prefix_id: ID | None = strawberry_django.filter_field() + rir: Annotated['RIRFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = strawberry_django.filter_field() + rir_id: ID | None = strawberry_django.filter_field() + date_added: DateFilterLookup[date] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.FHRPGroup, lookups=True) +class FHRPGroupFilterV1(PrimaryModelFilterMixinV1): + group_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + name: FilterLookup[str] | None = strawberry_django.filter_field() + protocol: Annotated['FHRPGroupProtocolEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + auth_type: Annotated['FHRPGroupAuthTypeEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + auth_key: FilterLookup[str] | None = strawberry_django.filter_field() + ip_addresses: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.FHRPGroupAssignment, lookups=True) +class FHRPGroupAssignmentFilterV1(BaseObjectTypeFilterMixinV1, ChangeLogFilterMixinV1): + interface_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + interface_id: FilterLookup[str] | None = strawberry_django.filter_field() + group: Annotated['FHRPGroupFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + group_id: ID | None = strawberry_django.filter_field() + priority: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + + @strawberry_django.filter_field() + def device_id(self, queryset, value: list[str], prefix) -> Q: + return self.filter_device('id', value) + + @strawberry_django.filter_field() + def device(self, value: list[str], prefix) -> Q: + return self.filter_device('name', value) + + @strawberry_django.filter_field() + def virtual_machine_id(self, value: list[str], prefix) -> Q: + return Q(interface_id__in=VMInterface.objects.filter(virtual_machine_id__in=value)) + + @strawberry_django.filter_field() + def virtual_machine(self, value: list[str], prefix) -> Q: + return Q(interface_id__in=VMInterface.objects.filter(virtual_machine__name__in=value)) + + def filter_device(self, field, value) -> Q: + """Helper to standardize logic for device and device_id filters""" + devices = Device.objects.filter(**{f'{field}__in': value}) + interface_ids = [] + for device in devices: + interface_ids.extend(device.vc_interfaces().values_list('id', flat=True)) + return Q(interface_id__in=interface_ids) + + +@strawberry_django.filter_type(models.IPAddress, lookups=True) +class IPAddressFilterV1(ContactFilterMixinV1, TenancyFilterMixinV1, PrimaryModelFilterMixinV1): + address: FilterLookup[str] | None = strawberry_django.filter_field() + vrf: Annotated['VRFFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = strawberry_django.filter_field() + vrf_id: ID | None = strawberry_django.filter_field() + status: Annotated['IPAddressStatusEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + role: Annotated['IPAddressRoleEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + assigned_object_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + assigned_object_id: ID | None = strawberry_django.filter_field() + nat_inside: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + nat_inside_id: ID | None = strawberry_django.filter_field() + nat_outside: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + nat_outside_id: ID | None = strawberry_django.filter_field() + dns_name: FilterLookup[str] | None = strawberry_django.filter_field() + + @strawberry_django.filter_field() + def assigned(self, value: bool, prefix) -> Q: + return Q(assigned_object_id__isnull=(not value)) + + @strawberry_django.filter_field() + def parent(self, value: list[str], prefix) -> Q: + if not value: + return Q() + q = Q() + for subnet in value: + try: + query = str(netaddr.IPNetwork(subnet.strip()).cidr) + q |= Q(address__net_host_contained=query) + except (AddrFormatError, ValueError): + return Q() + return q + + @strawberry_django.filter_field() + def family( + self, + value: Annotated['IPAddressFamilyEnum', strawberry.lazy('ipam.graphql.enums')], + prefix, + ) -> Q: + return Q(**{f"{prefix}address__family": value.value}) + + +@strawberry_django.filter_type(models.IPRange, lookups=True) +class IPRangeFilterV1(ContactFilterMixinV1, TenancyFilterMixinV1, PrimaryModelFilterMixinV1): + start_address: FilterLookup[str] | None = strawberry_django.filter_field() + end_address: FilterLookup[str] | None = strawberry_django.filter_field() + size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + vrf: Annotated['VRFFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = strawberry_django.filter_field() + vrf_id: ID | None = strawberry_django.filter_field() + status: Annotated['IPRangeStatusEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + role: Annotated['RoleFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + mark_utilized: FilterLookup[bool] | None = strawberry_django.filter_field() + + @strawberry_django.filter_field() + def parent(self, value: list[str], prefix) -> Q: + if not value: + return Q() + q = Q() + for subnet in value: + try: + query = str(netaddr.IPNetwork(subnet.strip()).cidr) + q |= Q(start_address__net_host_contained=query, end_address__net_host_contained=query) + except (AddrFormatError, ValueError): + return Q() + return q + + @strawberry_django.filter_field() + def contains(self, value: list[str], prefix) -> Q: + if not value: + return Q() + q = Q() + for subnet in value: + net = netaddr.IPNetwork(subnet.strip()) + q |= Q( + start_address__host__inet__lte=str(netaddr.IPAddress(net.first)), + end_address__host__inet__gte=str(netaddr.IPAddress(net.last)), + ) + return q + + +@strawberry_django.filter_type(models.Prefix, lookups=True) +class PrefixFilterV1(ContactFilterMixinV1, ScopedFilterMixinV1, TenancyFilterMixinV1, PrimaryModelFilterMixinV1): + prefix: FilterLookup[str] | None = strawberry_django.filter_field() + vrf: Annotated['VRFFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = strawberry_django.filter_field() + vrf_id: ID | None = strawberry_django.filter_field() + vlan: Annotated['VLANFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + vlan_id: ID | None = strawberry_django.filter_field() + status: Annotated['PrefixStatusEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + role: Annotated['RoleFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + role_id: ID | None = strawberry_django.filter_field() + is_pool: FilterLookup[bool] | None = strawberry_django.filter_field() + mark_utilized: FilterLookup[bool] | None = strawberry_django.filter_field() + + @strawberry_django.filter_field() + def contains(self, value: list[str], prefix) -> Q: + if not value: + return Q() + q = Q() + for subnet in value: + query = str(netaddr.IPNetwork(subnet.strip()).cidr) + q |= Q(prefix__net_contains=query) + return q + + +@strawberry_django.filter_type(models.RIR, lookups=True) +class RIRFilterV1(OrganizationalModelFilterMixinV1): + is_private: FilterLookup[bool] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.Role, lookups=True) +class RoleFilterV1(OrganizationalModelFilterMixinV1): + weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.RouteTarget, lookups=True) +class RouteTargetFilterV1(TenancyFilterMixinV1, PrimaryModelFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + importing_vrfs: Annotated['VRFFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + exporting_vrfs: Annotated['VRFFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + importing_l2vpns: Annotated['L2VPNFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + exporting_l2vpns: Annotated['L2VPNFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.Service, lookups=True) +class ServiceFilterV1(ContactFilterMixinV1, ServiceBaseFilterMixinV1, PrimaryModelFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + ip_addresses: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + parent_object_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + parent_object_id: ID | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.ServiceTemplate, lookups=True) +class ServiceTemplateFilterV1(ServiceBaseFilterMixinV1, PrimaryModelFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.VLAN, lookups=True) +class VLANFilterV1(TenancyFilterMixinV1, PrimaryModelFilterMixinV1): + site: Annotated['SiteFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + site_id: ID | None = strawberry_django.filter_field() + group: Annotated['VLANGroupFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + group_id: ID | None = strawberry_django.filter_field() + vid: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + name: FilterLookup[str] | None = strawberry_django.filter_field() + status: Annotated['VLANStatusEnum', strawberry.lazy('ipam.graphql.enums')] | None = strawberry_django.filter_field() + role: Annotated['RoleFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + role_id: ID | None = strawberry_django.filter_field() + qinq_svlan: Annotated['VLANFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + qinq_svlan_id: ID | None = strawberry_django.filter_field() + qinq_cvlans: Annotated['VLANFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + qinq_role: Annotated['VLANQinQRoleEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + l2vpn_terminations: Annotated['L2VPNFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.VLANGroup, lookups=True) +class VLANGroupFilterV1(ScopedFilterMixinV1, OrganizationalModelFilterMixinV1): + vid_ranges: Annotated['IntegerRangeArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.VLANTranslationPolicy, lookups=True) +class VLANTranslationPolicyFilterV1(PrimaryModelFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.VLANTranslationRule, lookups=True) +class VLANTranslationRuleFilterV1(NetBoxModelFilterMixinV1): + policy: Annotated['VLANTranslationPolicyFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + policy_id: ID | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + local_vid: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + remote_vid: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.VRF, lookups=True) +class VRFFilterV1(TenancyFilterMixinV1, PrimaryModelFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + rd: FilterLookup[str] | None = strawberry_django.filter_field() + enforce_unique: FilterLookup[bool] | None = strawberry_django.filter_field() + import_targets: Annotated['RouteTargetFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + export_targets: Annotated['RouteTargetFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) diff --git a/netbox/ipam/graphql/mixins_v1.py b/netbox/ipam/graphql/mixins_v1.py new file mode 100644 index 000000000..6d3e31197 --- /dev/null +++ b/netbox/ipam/graphql/mixins_v1.py @@ -0,0 +1,18 @@ +from typing import Annotated, List + +import strawberry + +__all__ = ( + 'IPAddressesMixinV1', + 'VLANGroupsMixinV1', +) + + +@strawberry.type +class IPAddressesMixinV1: + ip_addresses: List[Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] # noqa: F821 + + +@strawberry.type +class VLANGroupsMixinV1: + vlan_groups: List[Annotated["VLANGroupTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] # noqa: F821 diff --git a/netbox/ipam/graphql/schema_v1.py b/netbox/ipam/graphql/schema_v1.py new file mode 100644 index 000000000..3bc7fdd97 --- /dev/null +++ b/netbox/ipam/graphql/schema_v1.py @@ -0,0 +1,63 @@ +from typing import List + +import strawberry +import strawberry_django + +from .types_v1 import * + + +@strawberry.type(name="Query") +class IPAMQueryV1: + asn: ASNTypeV1 = strawberry_django.field() + asn_list: List[ASNTypeV1] = strawberry_django.field() + + asn_range: ASNRangeTypeV1 = strawberry_django.field() + asn_range_list: List[ASNRangeTypeV1] = strawberry_django.field() + + aggregate: AggregateTypeV1 = strawberry_django.field() + aggregate_list: List[AggregateTypeV1] = strawberry_django.field() + + ip_address: IPAddressTypeV1 = strawberry_django.field() + ip_address_list: List[IPAddressTypeV1] = strawberry_django.field() + + ip_range: IPRangeTypeV1 = strawberry_django.field() + ip_range_list: List[IPRangeTypeV1] = strawberry_django.field() + + prefix: PrefixTypeV1 = strawberry_django.field() + prefix_list: List[PrefixTypeV1] = strawberry_django.field() + + rir: RIRTypeV1 = strawberry_django.field() + rir_list: List[RIRTypeV1] = strawberry_django.field() + + role: RoleTypeV1 = strawberry_django.field() + role_list: List[RoleTypeV1] = strawberry_django.field() + + route_target: RouteTargetTypeV1 = strawberry_django.field() + route_target_list: List[RouteTargetTypeV1] = strawberry_django.field() + + service: ServiceTypeV1 = strawberry_django.field() + service_list: List[ServiceTypeV1] = strawberry_django.field() + + service_template: ServiceTemplateTypeV1 = strawberry_django.field() + service_template_list: List[ServiceTemplateTypeV1] = strawberry_django.field() + + fhrp_group: FHRPGroupTypeV1 = strawberry_django.field() + fhrp_group_list: List[FHRPGroupTypeV1] = strawberry_django.field() + + fhrp_group_assignment: FHRPGroupAssignmentTypeV1 = strawberry_django.field() + fhrp_group_assignment_list: List[FHRPGroupAssignmentTypeV1] = strawberry_django.field() + + vlan: VLANTypeV1 = strawberry_django.field() + vlan_list: List[VLANTypeV1] = strawberry_django.field() + + vlan_group: VLANGroupTypeV1 = strawberry_django.field() + vlan_group_list: List[VLANGroupTypeV1] = strawberry_django.field() + + vlan_translation_policy: VLANTranslationPolicyTypeV1 = strawberry_django.field() + vlan_translation_policy_list: List[VLANTranslationPolicyTypeV1] = strawberry_django.field() + + vlan_translation_rule: VLANTranslationRuleTypeV1 = strawberry_django.field() + vlan_translation_rule_list: List[VLANTranslationRuleTypeV1] = strawberry_django.field() + + vrf: VRFTypeV1 = strawberry_django.field() + vrf_list: List[VRFTypeV1] = strawberry_django.field() diff --git a/netbox/ipam/graphql/types_v1.py b/netbox/ipam/graphql/types_v1.py new file mode 100644 index 000000000..7b5d2c652 --- /dev/null +++ b/netbox/ipam/graphql/types_v1.py @@ -0,0 +1,360 @@ +from typing import Annotated, List, TYPE_CHECKING, Union + +import strawberry +import strawberry_django + +from circuits.graphql.types_v1 import ProviderTypeV1 +from dcim.graphql.types_v1 import SiteTypeV1 +from extras.graphql.mixins_v1 import ContactsMixinV1 +from ipam import models +from netbox.graphql.scalars import BigInt +from netbox.graphql.types_v1 import BaseObjectTypeV1, NetBoxObjectTypeV1, OrganizationalObjectTypeV1 +from .filters_v1 import * +from .mixins_v1 import IPAddressesMixinV1 + +if TYPE_CHECKING: + from dcim.graphql.types_v1 import ( + DeviceTypeV1, + InterfaceTypeV1, + LocationTypeV1, + RackTypeV1, + RegionTypeV1, + SiteGroupTypeV1, + SiteTypeV1, + ) + from tenancy.graphql.types_v1 import TenantTypeV1 + from virtualization.graphql.types_v1 import ( + ClusterGroupTypeV1, ClusterTypeV1, VMInterfaceTypeV1, VirtualMachineTypeV1 + ) + from vpn.graphql.types_v1 import L2VPNTypeV1, TunnelTerminationTypeV1 + from wireless.graphql.types_v1 import WirelessLANTypeV1 + +__all__ = ( + 'ASNTypeV1', + 'ASNRangeTypeV1', + 'AggregateTypeV1', + 'FHRPGroupTypeV1', + 'FHRPGroupAssignmentTypeV1', + 'IPAddressTypeV1', + 'IPRangeTypeV1', + 'PrefixTypeV1', + 'RIRTypeV1', + 'RoleTypeV1', + 'RouteTargetTypeV1', + 'ServiceTypeV1', + 'ServiceTemplateTypeV1', + 'VLANTypeV1', + 'VLANGroupTypeV1', + 'VLANTranslationPolicyTypeV1', + 'VLANTranslationRuleTypeV1', + 'VRFTypeV1', +) + + +@strawberry.type +class IPAddressFamilyTypeV1: + value: int + label: str + + +@strawberry.type +class BaseIPAddressFamilyTypeV1: + """ + Base type for models that need to expose their IPAddress family type. + """ + + @strawberry.field + def family(self) -> IPAddressFamilyTypeV1: + # Note that self, is an instance of models.IPAddress + # thus resolves to the address family value. + return IPAddressFamilyTypeV1(value=self.family, label=f'IPv{self.family}') + + +@strawberry_django.type( + models.ASN, + fields='__all__', + filters=ASNFilterV1, + pagination=True +) +class ASNTypeV1(NetBoxObjectTypeV1, ContactsMixinV1): + asn: BigInt + rir: Annotated["RIRTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None + + sites: List[SiteTypeV1] + providers: List[ProviderTypeV1] + + +@strawberry_django.type( + models.ASNRange, + fields='__all__', + filters=ASNRangeFilterV1, + pagination=True +) +class ASNRangeTypeV1(NetBoxObjectTypeV1): + start: BigInt + end: BigInt + rir: Annotated["RIRTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None + + +@strawberry_django.type( + models.Aggregate, + fields='__all__', + filters=AggregateFilterV1, + pagination=True +) +class AggregateTypeV1(NetBoxObjectTypeV1, ContactsMixinV1, BaseIPAddressFamilyTypeV1): + prefix: str + rir: Annotated["RIRTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None + + +@strawberry_django.type( + models.FHRPGroup, + fields='__all__', + filters=FHRPGroupFilterV1, + pagination=True +) +class FHRPGroupTypeV1(NetBoxObjectTypeV1, IPAddressesMixinV1): + + fhrpgroupassignment_set: List[Annotated["FHRPGroupAssignmentTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] + + +@strawberry_django.type( + models.FHRPGroupAssignment, + exclude=['interface_type', 'interface_id'], + filters=FHRPGroupAssignmentFilterV1, + pagination=True +) +class FHRPGroupAssignmentTypeV1(BaseObjectTypeV1): + group: Annotated["FHRPGroupTypeV1", strawberry.lazy('ipam.graphql.types_v1')] + + @strawberry_django.field + def interface(self) -> Annotated[Union[ + Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["VMInterfaceTypeV1", strawberry.lazy('virtualization.graphql.types_v1')], + ], strawberry.union("FHRPGroupInterfaceTypeV1")]: + return self.interface + + +@strawberry_django.type( + models.IPAddress, + exclude=['assigned_object_type', 'assigned_object_id', 'address'], + filters=IPAddressFilterV1, + pagination=True +) +class IPAddressTypeV1(NetBoxObjectTypeV1, ContactsMixinV1, BaseIPAddressFamilyTypeV1): + address: str + vrf: Annotated["VRFTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None + nat_inside: Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + + nat_outside: List[Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] + tunnel_terminations: List[Annotated["TunnelTerminationTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] + services: List[Annotated["ServiceTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] + + @strawberry_django.field + def assigned_object(self) -> Annotated[Union[ + Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["FHRPGroupTypeV1", strawberry.lazy('ipam.graphql.types_v1')], + Annotated["VMInterfaceTypeV1", strawberry.lazy('virtualization.graphql.types_v1')], + ], strawberry.union("IPAddressAssignmentTypeV1")] | None: + return self.assigned_object + + +@strawberry_django.type( + models.IPRange, + fields='__all__', + filters=IPRangeFilterV1, + pagination=True +) +class IPRangeTypeV1(NetBoxObjectTypeV1, ContactsMixinV1): + start_address: str + end_address: str + vrf: Annotated["VRFTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None + role: Annotated["RoleTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + + +@strawberry_django.type( + models.Prefix, + exclude=['scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'], + filters=PrefixFilterV1, + pagination=True +) +class PrefixTypeV1(NetBoxObjectTypeV1, ContactsMixinV1, BaseIPAddressFamilyTypeV1): + prefix: str + vrf: Annotated["VRFTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None + vlan: Annotated["VLANTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + role: Annotated["RoleTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + + @strawberry_django.field + def scope(self) -> Annotated[Union[ + Annotated["LocationTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["RegionTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["SiteGroupTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["SiteTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + ], strawberry.union("PrefixScopeTypeV1")] | None: + return self.scope + + +@strawberry_django.type( + models.RIR, + fields='__all__', + filters=RIRFilterV1, + pagination=True +) +class RIRTypeV1(OrganizationalObjectTypeV1): + + asn_ranges: List[Annotated["ASNRangeTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] + asns: List[Annotated["ASNTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] + aggregates: List[Annotated["AggregateTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] + + +@strawberry_django.type( + models.Role, + fields='__all__', + filters=RoleFilterV1, + pagination=True +) +class RoleTypeV1(OrganizationalObjectTypeV1): + + prefixes: List[Annotated["PrefixTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] + ip_ranges: List[Annotated["IPRangeTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] + vlans: List[Annotated["VLANTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] + + +@strawberry_django.type( + models.RouteTarget, + fields='__all__', + filters=RouteTargetFilterV1, + pagination=True +) +class RouteTargetTypeV1(NetBoxObjectTypeV1): + tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None + + importing_l2vpns: List[Annotated["L2VPNTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] + exporting_l2vpns: List[Annotated["L2VPNTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] + importing_vrfs: List[Annotated["VRFTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] + exporting_vrfs: List[Annotated["VRFTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] + + +@strawberry_django.type( + models.Service, + exclude=('parent_object_type', 'parent_object_id'), + filters=ServiceFilterV1, + pagination=True +) +class ServiceTypeV1(NetBoxObjectTypeV1, ContactsMixinV1): + ports: List[int] + ipaddresses: List[Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] + + @strawberry_django.field + def parent(self) -> Annotated[Union[ + Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["VirtualMachineTypeV1", strawberry.lazy('virtualization.graphql.types_v1')], + Annotated["FHRPGroupTypeV1", strawberry.lazy('ipam.graphql.types_v1')], + ], strawberry.union("ServiceParentTypeV1")] | None: + return self.parent + + +@strawberry_django.type( + models.ServiceTemplate, + fields='__all__', + filters=ServiceTemplateFilterV1, + pagination=True +) +class ServiceTemplateTypeV1(NetBoxObjectTypeV1): + ports: List[int] + + +@strawberry_django.type( + models.VLAN, + exclude=['qinq_svlan'], + filters=VLANFilterV1, + pagination=True +) +class VLANTypeV1(NetBoxObjectTypeV1): + site: Annotated["SiteTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + group: Annotated["VLANGroupTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None + role: Annotated["RoleTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + + interfaces_as_untagged: List[Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + vminterfaces_as_untagged: List[Annotated["VMInterfaceTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] + wirelesslan_set: List[Annotated["WirelessLANTypeV1", strawberry.lazy('wireless.graphql.types_v1')]] + prefixes: List[Annotated["PrefixTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] + interfaces_as_tagged: List[Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + vminterfaces_as_tagged: List[Annotated["VMInterfaceTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] + + @strawberry_django.field + def qinq_svlan(self) -> Annotated["VLANTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None: + return self.qinq_svlan + + +@strawberry_django.type( + models.VLANGroup, + exclude=['scope_type', 'scope_id'], + filters=VLANGroupFilterV1, + pagination=True +) +class VLANGroupTypeV1(OrganizationalObjectTypeV1): + + vlans: List[VLANTypeV1] + vid_ranges: List[str] + tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None + + @strawberry_django.field + def scope(self) -> Annotated[Union[ + Annotated["ClusterTypeV1", strawberry.lazy('virtualization.graphql.types_v1')], + Annotated["ClusterGroupTypeV1", strawberry.lazy('virtualization.graphql.types_v1')], + Annotated["LocationTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["RackTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["RegionTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["SiteTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["SiteGroupTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + ], strawberry.union("VLANGroupScopeTypeV1")] | None: + return self.scope + + +@strawberry_django.type( + models.VLANTranslationPolicy, + fields='__all__', + filters=VLANTranslationPolicyFilterV1, + pagination=True +) +class VLANTranslationPolicyTypeV1(NetBoxObjectTypeV1): + rules: List[Annotated["VLANTranslationRuleTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] + + +@strawberry_django.type( + models.VLANTranslationRule, + fields='__all__', + filters=VLANTranslationRuleFilterV1, + pagination=True +) +class VLANTranslationRuleTypeV1(NetBoxObjectTypeV1): + policy: Annotated[ + "VLANTranslationPolicyTypeV1", + strawberry.lazy('ipam.graphql.types_v1') + ] = strawberry_django.field(select_related=["policy"]) + + +@strawberry_django.type( + models.VRF, + fields='__all__', + filters=VRFFilterV1, + pagination=True +) +class VRFTypeV1(NetBoxObjectTypeV1): + tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None + + interfaces: List[Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + ip_addresses: List[Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] + vminterfaces: List[Annotated["VMInterfaceTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] + ip_ranges: List[Annotated["IPRangeTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] + export_targets: List[Annotated["RouteTargetTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] + import_targets: List[Annotated["RouteTargetTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] + prefixes: List[Annotated["PrefixTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] diff --git a/netbox/netbox/graphql/filter_mixins_v1.py b/netbox/netbox/graphql/filter_mixins_v1.py new file mode 100644 index 000000000..2d34ff12d --- /dev/null +++ b/netbox/netbox/graphql/filter_mixins_v1.py @@ -0,0 +1,104 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import TypeVar, TYPE_CHECKING, Annotated + +import strawberry +import strawberry_django +from strawberry import ID +from strawberry_django import FilterLookup, DatetimeFilterLookup + +from core.graphql.filter_mixins_v1 import BaseFilterMixinV1, BaseObjectTypeFilterMixinV1, ChangeLogFilterMixinV1 +from extras.graphql.filter_mixins_v1 import CustomFieldsFilterMixinV1, JournalEntriesFilterMixinV1, TagsFilterMixinV1 + +__all__ = ( + 'DistanceFilterMixinV1', + 'ImageAttachmentFilterMixinV1', + 'NestedGroupModelFilterMixinV1', + 'NetBoxModelFilterMixinV1', + 'OrganizationalModelFilterMixinV1', + 'PrimaryModelFilterMixinV1', + 'SyncedDataFilterMixinV1', + 'WeightFilterMixinV1', +) + +T = TypeVar('T') + + +if TYPE_CHECKING: + from .enums import * + from core.graphql.filters_v1 import * + from extras.graphql.filters_v1 import * + + +class NetBoxModelFilterMixinV1( + ChangeLogFilterMixinV1, + CustomFieldsFilterMixinV1, + JournalEntriesFilterMixinV1, + TagsFilterMixinV1, + BaseObjectTypeFilterMixinV1, +): + pass + + +@dataclass +class NestedGroupModelFilterMixinV1(NetBoxModelFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + parent_id: ID | None = strawberry_django.filter_field() + + +@dataclass +class OrganizationalModelFilterMixinV1( + ChangeLogFilterMixinV1, + CustomFieldsFilterMixinV1, + TagsFilterMixinV1, + BaseObjectTypeFilterMixinV1, +): + name: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + + +@dataclass +class PrimaryModelFilterMixinV1(NetBoxModelFilterMixinV1): + description: FilterLookup[str] | None = strawberry_django.filter_field() + comments: FilterLookup[str] | None = strawberry_django.filter_field() + + +@dataclass +class ImageAttachmentFilterMixinV1(BaseFilterMixinV1): + images: Annotated['ImageAttachmentFilterV1', strawberry.lazy('extras.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@dataclass +class WeightFilterMixinV1(BaseFilterMixinV1): + weight: FilterLookup[float] | None = strawberry_django.filter_field() + weight_unit: Annotated['WeightUnitEnum', strawberry.lazy('netbox.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + + +@dataclass +class SyncedDataFilterMixinV1(BaseFilterMixinV1): + data_source: Annotated['DataSourceFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + data_source_id: FilterLookup[int] | None = strawberry_django.filter_field() + data_file: Annotated['DataFileFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + data_file_id: FilterLookup[int] | None = strawberry_django.filter_field() + data_path: FilterLookup[str] | None = strawberry_django.filter_field() + auto_sync_enabled: FilterLookup[bool] | None = strawberry_django.filter_field() + data_synced: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() + + +@dataclass +class DistanceFilterMixinV1(BaseFilterMixinV1): + distance: FilterLookup[float] | None = strawberry_django.filter_field() + distance_unit: Annotated['DistanceUnitEnum', strawberry.lazy('netbox.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py index 70a6ec7bf..54fe61712 100644 --- a/netbox/netbox/graphql/schema.py +++ b/netbox/netbox/graphql/schema.py @@ -4,16 +4,26 @@ from strawberry_django.optimizer import DjangoOptimizerExtension from strawberry.extensions import MaxAliasesLimiter from strawberry.schema.config import StrawberryConfig +from circuits.graphql.schema_v1 import CircuitsQueryV1 from circuits.graphql.schema import CircuitsQuery +from core.graphql.schema_v1 import CoreQueryV1 from core.graphql.schema import CoreQuery +from dcim.graphql.schema_v1 import DCIMQueryV1 from dcim.graphql.schema import DCIMQuery +from extras.graphql.schema_v1 import ExtrasQueryV1 from extras.graphql.schema import ExtrasQuery +from ipam.graphql.schema_v1 import IPAMQueryV1 from ipam.graphql.schema import IPAMQuery from netbox.registry import registry +from tenancy.graphql.schema_v1 import TenancyQueryV1 from tenancy.graphql.schema import TenancyQuery +from users.graphql.schema_v1 import UsersQueryV1 from users.graphql.schema import UsersQuery +from virtualization.graphql.schema_v1 import VirtualizationQueryV1 from virtualization.graphql.schema import VirtualizationQuery +from vpn.graphql.schema_v1 import VPNQueryV1 from vpn.graphql.schema import VPNQuery +from wireless.graphql.schema_v1 import WirelessQueryV1 from wireless.graphql.schema import WirelessQuery __all__ = ( @@ -27,16 +37,16 @@ __all__ = ( @strawberry.type class QueryV1( - UsersQuery, - CircuitsQuery, - CoreQuery, - DCIMQuery, - ExtrasQuery, - IPAMQuery, - TenancyQuery, - VirtualizationQuery, - VPNQuery, - WirelessQuery, + UsersQueryV1, + CircuitsQueryV1, + CoreQueryV1, + DCIMQueryV1, + ExtrasQueryV1, + IPAMQueryV1, + TenancyQueryV1, + VirtualizationQueryV1, + VPNQueryV1, + WirelessQueryV1, *registry['plugins']['graphql_schemas'], # Append plugin schemas ): """Query class for GraphQL API v1""" diff --git a/netbox/netbox/graphql/types_v1.py b/netbox/netbox/graphql/types_v1.py new file mode 100644 index 000000000..c6b3ad2ec --- /dev/null +++ b/netbox/netbox/graphql/types_v1.py @@ -0,0 +1,100 @@ +import strawberry +import strawberry_django +from strawberry.types import Info +from django.contrib.contenttypes.models import ContentType + +from core.graphql.mixins import ChangelogMixin +from core.models import ObjectType as ObjectType_ +from extras.graphql.mixins import CustomFieldsMixin, JournalEntriesMixin, TagsMixin + +__all__ = ( + 'BaseObjectTypeV1', + 'ContentTypeTypeV1', + 'ObjectTypeV1', + 'OrganizationalObjectTypeV1', + 'NetBoxObjectTypeV1', +) + + +# +# Base types +# + +@strawberry.type +class BaseObjectTypeV1: + """ + Base GraphQL object type for all NetBox objects. Restricts the model queryset to enforce object permissions. + """ + + @classmethod + def get_queryset(cls, queryset, info: Info, **kwargs): + # Enforce object permissions on the queryset + if hasattr(queryset, 'restrict'): + return queryset.restrict(info.context.request.user, 'view') + else: + return queryset + + @strawberry_django.field + def display(self) -> str: + return str(self) + + @strawberry_django.field + def class_type(self) -> str: + return self.__class__.__name__ + + +class ObjectTypeV1( + ChangelogMixin, + BaseObjectTypeV1 +): + """ + Base GraphQL object type for unclassified models which support change logging + """ + pass + + +class OrganizationalObjectTypeV1( + ChangelogMixin, + CustomFieldsMixin, + TagsMixin, + BaseObjectTypeV1 +): + """ + Base type for organizational models + """ + pass + + +class NetBoxObjectTypeV1( + ChangelogMixin, + CustomFieldsMixin, + JournalEntriesMixin, + TagsMixin, + BaseObjectTypeV1 +): + """ + GraphQL type for most NetBox models. Includes support for custom fields, change logging, journaling, and tags. + """ + pass + + +# +# Miscellaneous types +# + +@strawberry_django.type( + ContentType, + fields=['id', 'app_label', 'model'], + pagination=True +) +class ContentTypeTypeV1: + pass + + +@strawberry_django.type( + ObjectType_, + fields=['id', 'app_label', 'model'], + pagination=True +) +class ObjectTypeTypeV1: + pass diff --git a/netbox/tenancy/graphql/filter_mixins_v1.py b/netbox/tenancy/graphql/filter_mixins_v1.py new file mode 100644 index 000000000..c5ffd914f --- /dev/null +++ b/netbox/tenancy/graphql/filter_mixins_v1.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django +from strawberry import ID + +from core.graphql.filter_mixins_v1 import BaseFilterMixinV1 + +if TYPE_CHECKING: + from netbox.graphql.filter_lookups import TreeNodeFilter + from .filters_v1 import ContactAssignmentFilterV1, TenantFilterV1, TenantGroupFilterV1 + +__all__ = ( + 'ContactFilterMixinV1', + 'TenancyFilterMixinV1', +) + + +@dataclass +class ContactFilterMixinV1(BaseFilterMixinV1): + contacts: Annotated['ContactAssignmentFilterV1', strawberry.lazy('tenancy.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@dataclass +class TenancyFilterMixinV1(BaseFilterMixinV1): + tenant: Annotated['TenantFilterV1', strawberry.lazy('tenancy.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + tenant_id: ID | None = strawberry_django.filter_field() + tenant_group: Annotated['TenantGroupFilterV1', strawberry.lazy('tenancy.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + tenant_group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) diff --git a/netbox/tenancy/graphql/filters_v1.py b/netbox/tenancy/graphql/filters_v1.py new file mode 100644 index 000000000..fb101858b --- /dev/null +++ b/netbox/tenancy/graphql/filters_v1.py @@ -0,0 +1,210 @@ +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django +from strawberry.scalars import ID +from strawberry_django import FilterLookup + +from core.graphql.filter_mixins_v1 import ChangeLogFilterMixinV1 +from extras.graphql.filter_mixins_v1 import CustomFieldsFilterMixinV1, TagsFilterMixinV1 +from netbox.graphql.filter_mixins_v1 import ( + NestedGroupModelFilterMixinV1, + OrganizationalModelFilterMixinV1, + PrimaryModelFilterMixinV1, +) +from tenancy import models +from .filter_mixins_v1 import ContactFilterMixinV1 + +if TYPE_CHECKING: + from core.graphql.filters_v1 import ContentTypeFilterV1 + from circuits.graphql.filters_v1 import CircuitFilterV1, CircuitGroupFilterV1, VirtualCircuitFilterV1 + from dcim.graphql.filters_v1 import ( + CableFilterV1, + DeviceFilterV1, + LocationFilterV1, + PowerFeedFilterV1, + RackFilterV1, + RackReservationFilterV1, + SiteFilterV1, + VirtualDeviceContextFilterV1, + ) + from ipam.graphql.filters_v1 import ( + AggregateFilterV1, + ASNFilterV1, + ASNRangeFilterV1, + IPAddressFilterV1, + IPRangeFilterV1, + PrefixFilterV1, + RouteTargetFilterV1, + VLANFilterV1, + VLANGroupFilterV1, + VRFFilterV1, + ) + from netbox.graphql.filter_lookups import TreeNodeFilter + from wireless.graphql.filters_v1 import WirelessLANFilterV1, WirelessLinkFilterV1 + from virtualization.graphql.filters_v1 import ClusterFilterV1, VirtualMachineFilterV1 + from vpn.graphql.filters_v1 import L2VPNFilterV1, TunnelFilterV1 + from .enums import * + +__all__ = ( + 'TenantFilterV1', + 'TenantGroupFilterV1', + 'ContactFilterV1', + 'ContactRoleFilterV1', + 'ContactGroupFilterV1', + 'ContactAssignmentFilterV1', +) + + +@strawberry_django.filter_type(models.Tenant, lookups=True) +class TenantFilterV1(PrimaryModelFilterMixinV1, ContactFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() + group: Annotated['TenantGroupFilterV1', strawberry.lazy('tenancy.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + + # Reverse relations + aggregates: Annotated['AggregateFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + asns: Annotated['ASNFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = strawberry_django.filter_field() + asn_ranges: Annotated['ASNRangeFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + cables: Annotated['CableFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + circuit_groups: Annotated['CircuitGroupFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + circuits: Annotated['CircuitFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + clusters: Annotated['ClusterFilterV1', strawberry.lazy('virtualization.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + devices: Annotated['DeviceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + ip_addresses: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + ip_ranges: Annotated['IPRangeFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + l2vpns: Annotated['L2VPNFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + locations: Annotated['LocationFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + power_feeds: Annotated['PowerFeedFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + prefixes: Annotated['PrefixFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + racks: Annotated['RackFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + rackreservations: Annotated['RackReservationFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + route_targets: Annotated['RouteTargetFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + sites: Annotated['SiteFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + tunnels: Annotated['TunnelFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + vdcs: Annotated['VirtualDeviceContextFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + virtual_machines: Annotated[ + 'VirtualMachineFilterV1', strawberry.lazy('virtualization.graphql.filters_v1') + ] | None = ( + strawberry_django.filter_field() + ) + vlan_groups: Annotated['VLANGroupFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + vlans: Annotated['VLANFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + virtual_circuits: Annotated['VirtualCircuitFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + vrfs: Annotated['VRFFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = strawberry_django.filter_field() + wireless_lans: Annotated['WirelessLANFilterV1', strawberry.lazy('wireless.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + wireless_links: Annotated['WirelessLinkFilterV1', strawberry.lazy('wireless.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.TenantGroup, lookups=True) +class TenantGroupFilterV1(OrganizationalModelFilterMixinV1): + parent: Annotated['TenantGroupFilterV1', strawberry.lazy('tenancy.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + parent_id: ID | None = strawberry.UNSET + tenants: Annotated['TenantFilterV1', strawberry.lazy('tenancy.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + children: Annotated['TenantGroupFilterV1', strawberry.lazy('tenancy.graphql.filters_v1'), True] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.Contact, lookups=True) +class ContactFilterV1(PrimaryModelFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + title: FilterLookup[str] | None = strawberry_django.filter_field() + phone: FilterLookup[str] | None = strawberry_django.filter_field() + email: FilterLookup[str] | None = strawberry_django.filter_field() + address: FilterLookup[str] | None = strawberry_django.filter_field() + link: FilterLookup[str] | None = strawberry_django.filter_field() + groups: Annotated['ContactGroupFilterV1', strawberry.lazy('tenancy.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + assignments: Annotated['ContactAssignmentFilterV1', strawberry.lazy('tenancy.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.ContactRole, lookups=True) +class ContactRoleFilterV1(OrganizationalModelFilterMixinV1): + pass + + +@strawberry_django.filter_type(models.ContactGroup, lookups=True) +class ContactGroupFilterV1(NestedGroupModelFilterMixinV1): + parent: Annotated['ContactGroupFilterV1', strawberry.lazy('tenancy.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.ContactAssignment, lookups=True) +class ContactAssignmentFilterV1(CustomFieldsFilterMixinV1, TagsFilterMixinV1, ChangeLogFilterMixinV1): + object_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + object_id: ID | None = strawberry_django.filter_field() + contact: Annotated['ContactFilterV1', strawberry.lazy('tenancy.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + contact_id: ID | None = strawberry_django.filter_field() + role: Annotated['ContactRoleFilterV1', strawberry.lazy('tenancy.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + role_id: ID | None = strawberry_django.filter_field() + priority: Annotated['ContactPriorityEnum', strawberry.lazy('tenancy.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) diff --git a/netbox/tenancy/graphql/mixins_v1.py b/netbox/tenancy/graphql/mixins_v1.py new file mode 100644 index 000000000..a6c31b68a --- /dev/null +++ b/netbox/tenancy/graphql/mixins_v1.py @@ -0,0 +1,12 @@ +from typing import Annotated, List + +import strawberry + +__all__ = ( + 'ContactAssignmentsMixinV1', +) + + +@strawberry.type +class ContactAssignmentsMixinV1: + assignments: List[Annotated["ContactAssignmentTypeV1", strawberry.lazy('tenancy.graphql.types_v1')]] # noqa: F821 diff --git a/netbox/tenancy/graphql/schema_v1.py b/netbox/tenancy/graphql/schema_v1.py new file mode 100644 index 000000000..135f1573f --- /dev/null +++ b/netbox/tenancy/graphql/schema_v1.py @@ -0,0 +1,27 @@ +from typing import List + +import strawberry +import strawberry_django + +from .types_v1 import * + + +@strawberry.type(name="Query") +class TenancyQueryV1: + tenant: TenantTypeV1 = strawberry_django.field() + tenant_list: List[TenantTypeV1] = strawberry_django.field() + + tenant_group: TenantGroupTypeV1 = strawberry_django.field() + tenant_group_list: List[TenantGroupTypeV1] = strawberry_django.field() + + contact: ContactTypeV1 = strawberry_django.field() + contact_list: List[ContactTypeV1] = strawberry_django.field() + + contact_role: ContactRoleTypeV1 = strawberry_django.field() + contact_role_list: List[ContactRoleTypeV1] = strawberry_django.field() + + contact_group: ContactGroupTypeV1 = strawberry_django.field() + contact_group_list: List[ContactGroupTypeV1] = strawberry_django.field() + + contact_assignment: ContactAssignmentTypeV1 = strawberry_django.field() + contact_assignment_list: List[ContactAssignmentTypeV1] = strawberry_django.field() diff --git a/netbox/tenancy/graphql/types_v1.py b/netbox/tenancy/graphql/types_v1.py new file mode 100644 index 000000000..cc4b774bc --- /dev/null +++ b/netbox/tenancy/graphql/types_v1.py @@ -0,0 +1,147 @@ +from typing import Annotated, List, TYPE_CHECKING + +import strawberry +import strawberry_django + +from extras.graphql.mixins_v1 import CustomFieldsMixinV1, TagsMixinV1, ContactsMixinV1 +from netbox.graphql.types_v1 import BaseObjectTypeV1, OrganizationalObjectTypeV1, NetBoxObjectTypeV1 +from tenancy import models +from .filters_v1 import * +from .mixins_v1 import ContactAssignmentsMixinV1 + +if TYPE_CHECKING: + from circuits.graphql.types_v1 import CircuitTypeV1 + from dcim.graphql.types_v1 import ( + CableTypeV1, + DeviceTypeV1, + LocationTypeV1, + PowerFeedTypeV1, + RackTypeV1, + RackReservationTypeV1, + SiteTypeV1, + VirtualDeviceContextTypeV1, + ) + from ipam.graphql.types_v1 import ( + AggregateTypeV1, + ASNTypeV1, + ASNRangeTypeV1, + IPAddressTypeV1, + IPRangeTypeV1, + PrefixTypeV1, + RouteTargetTypeV1, + VLANTypeV1, + VRFTypeV1, + ) + from netbox.graphql.types_v1 import ContentTypeTypeV1 + from wireless.graphql.types_v1 import WirelessLANTypeV1, WirelessLinkTypeV1 + from virtualization.graphql.types_v1 import ClusterTypeV1, VirtualMachineTypeV1 + from vpn.graphql.types_v1 import L2VPNTypeV1, TunnelTypeV1 + +__all__ = ( + 'ContactAssignmentTypeV1', + 'ContactGroupTypeV1', + 'ContactRoleTypeV1', + 'ContactTypeV1', + 'TenantTypeV1', + 'TenantGroupTypeV1', +) + + +# +# Tenants +# + +@strawberry_django.type( + models.Tenant, + fields='__all__', + filters=TenantFilterV1, + pagination=True +) +class TenantTypeV1(ContactsMixinV1, NetBoxObjectTypeV1): + group: Annotated['TenantGroupTypeV1', strawberry.lazy('tenancy.graphql.types_v1')] | None + asns: List[Annotated['ASNTypeV1', strawberry.lazy('ipam.graphql.types_v1')]] + circuits: List[Annotated['CircuitTypeV1', strawberry.lazy('circuits.graphql.types_v1')]] + sites: List[Annotated['SiteTypeV1', strawberry.lazy('dcim.graphql.types_v1')]] + vlans: List[Annotated['VLANTypeV1', strawberry.lazy('ipam.graphql.types_v1')]] + wireless_lans: List[Annotated['WirelessLANTypeV1', strawberry.lazy('wireless.graphql.types_v1')]] + route_targets: List[Annotated['RouteTargetTypeV1', strawberry.lazy('ipam.graphql.types_v1')]] + locations: List[Annotated['LocationTypeV1', strawberry.lazy('dcim.graphql.types_v1')]] + ip_ranges: List[Annotated['IPRangeTypeV1', strawberry.lazy('ipam.graphql.types_v1')]] + rackreservations: List[Annotated['RackReservationTypeV1', strawberry.lazy('dcim.graphql.types_v1')]] + racks: List[Annotated['RackTypeV1', strawberry.lazy('dcim.graphql.types_v1')]] + vdcs: List[Annotated['VirtualDeviceContextTypeV1', strawberry.lazy('dcim.graphql.types_v1')]] + prefixes: List[Annotated['PrefixTypeV1', strawberry.lazy('ipam.graphql.types_v1')]] + cables: List[Annotated['CableTypeV1', strawberry.lazy('dcim.graphql.types_v1')]] + virtual_machines: List[Annotated['VirtualMachineTypeV1', strawberry.lazy('virtualization.graphql.types_v1')]] + vrfs: List[Annotated['VRFTypeV1', strawberry.lazy('ipam.graphql.types_v1')]] + asn_ranges: List[Annotated['ASNRangeTypeV1', strawberry.lazy('ipam.graphql.types_v1')]] + wireless_links: List[Annotated['WirelessLinkTypeV1', strawberry.lazy('wireless.graphql.types_v1')]] + aggregates: List[Annotated['AggregateTypeV1', strawberry.lazy('ipam.graphql.types_v1')]] + power_feeds: List[Annotated['PowerFeedTypeV1', strawberry.lazy('dcim.graphql.types_v1')]] + devices: List[Annotated['DeviceTypeV1', strawberry.lazy('dcim.graphql.types_v1')]] + tunnels: List[Annotated['TunnelTypeV1', strawberry.lazy('vpn.graphql.types_v1')]] + ip_addresses: List[Annotated['IPAddressTypeV1', strawberry.lazy('ipam.graphql.types_v1')]] + clusters: List[Annotated['ClusterTypeV1', strawberry.lazy('virtualization.graphql.types_v1')]] + l2vpns: List[Annotated['L2VPNTypeV1', strawberry.lazy('vpn.graphql.types_v1')]] + + +@strawberry_django.type( + models.TenantGroup, + fields='__all__', + filters=TenantGroupFilterV1, + pagination=True +) +class TenantGroupTypeV1(OrganizationalObjectTypeV1): + parent: Annotated['TenantGroupTypeV1', strawberry.lazy('tenancy.graphql.types_v1')] | None + + tenants: List[TenantTypeV1] + children: List[Annotated['TenantGroupTypeV1', strawberry.lazy('tenancy.graphql.types_v1')]] + + +# +# Contacts +# + +@strawberry_django.type( + models.Contact, + fields='__all__', + filters=ContactFilterV1, + pagination=True +) +class ContactTypeV1(ContactAssignmentsMixinV1, NetBoxObjectTypeV1): + groups: List[Annotated['ContactGroupTypeV1', strawberry.lazy('tenancy.graphql.types_v1')]] + + +@strawberry_django.type( + models.ContactRole, + fields='__all__', + filters=ContactRoleFilterV1, + pagination=True +) +class ContactRoleTypeV1(ContactAssignmentsMixinV1, OrganizationalObjectTypeV1): + pass + + +@strawberry_django.type( + models.ContactGroup, + fields='__all__', + filters=ContactGroupFilterV1, + pagination=True +) +class ContactGroupTypeV1(OrganizationalObjectTypeV1): + parent: Annotated['ContactGroupTypeV1', strawberry.lazy('tenancy.graphql.types_v1')] | None + + contacts: List[ContactTypeV1] + children: List[Annotated['ContactGroupTypeV1', strawberry.lazy('tenancy.graphql.types_v1')]] + + +@strawberry_django.type( + models.ContactAssignment, + fields='__all__', + filters=ContactAssignmentFilterV1, + pagination=True +) +class ContactAssignmentTypeV1(CustomFieldsMixinV1, TagsMixinV1, BaseObjectTypeV1): + object_type: Annotated['ContentTypeTypeV1', strawberry.lazy('netbox.graphql.types_v1')] | None + contact: Annotated['ContactTypeV1', strawberry.lazy('tenancy.graphql.types_v1')] | None + role: Annotated['ContactRoleTypeV1', strawberry.lazy('tenancy.graphql.types_v1')] | None diff --git a/netbox/users/graphql/filters_v1.py b/netbox/users/graphql/filters_v1.py new file mode 100644 index 000000000..1b08ed6fc --- /dev/null +++ b/netbox/users/graphql/filters_v1.py @@ -0,0 +1,34 @@ +from datetime import datetime +from typing import Annotated + +import strawberry +import strawberry_django +from strawberry_django import DatetimeFilterLookup, FilterLookup + +from core.graphql.filter_mixins_v1 import BaseObjectTypeFilterMixinV1 +from users import models + +__all__ = ( + 'GroupFilterV1', + 'UserFilterV1', +) + + +@strawberry_django.filter_type(models.Group, lookups=True) +class GroupFilterV1(BaseObjectTypeFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.User, lookups=True) +class UserFilterV1(BaseObjectTypeFilterMixinV1): + username: FilterLookup[str] | None = strawberry_django.filter_field() + first_name: FilterLookup[str] | None = strawberry_django.filter_field() + last_name: FilterLookup[str] | None = strawberry_django.filter_field() + email: FilterLookup[str] | None = strawberry_django.filter_field() + is_superuser: FilterLookup[bool] | None = strawberry_django.filter_field() + is_active: FilterLookup[bool] | None = strawberry_django.filter_field() + date_joined: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() + last_login: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() + groups: Annotated['GroupFilterV1', strawberry.lazy('users.graphql.filters_v1')] | None = ( + strawberry_django.filter_field()) diff --git a/netbox/users/graphql/schema_v1.py b/netbox/users/graphql/schema_v1.py new file mode 100644 index 000000000..69c3b9d57 --- /dev/null +++ b/netbox/users/graphql/schema_v1.py @@ -0,0 +1,15 @@ +from typing import List + +import strawberry +import strawberry_django + +from .types_v1 import * + + +@strawberry.type(name="Query") +class UsersQueryV1: + group: GroupTypeV1 = strawberry_django.field() + group_list: List[GroupTypeV1] = strawberry_django.field() + + user: UserTypeV1 = strawberry_django.field() + user_list: List[UserTypeV1] = strawberry_django.field() diff --git a/netbox/users/graphql/types_v1.py b/netbox/users/graphql/types_v1.py new file mode 100644 index 000000000..3b48431d0 --- /dev/null +++ b/netbox/users/graphql/types_v1.py @@ -0,0 +1,34 @@ +from typing import List + +import strawberry_django + +from netbox.graphql.types_v1 import BaseObjectTypeV1 +from users.models import Group, User +from .filters_v1 import * + +__all__ = ( + 'GroupTypeV1', + 'UserTypeV1', +) + + +@strawberry_django.type( + Group, + fields=['id', 'name'], + filters=GroupFilterV1, + pagination=True +) +class GroupTypeV1(BaseObjectTypeV1): + pass + + +@strawberry_django.type( + User, + fields=[ + 'id', 'username', 'first_name', 'last_name', 'email', 'is_active', 'date_joined', 'groups', + ], + filters=UserFilterV1, + pagination=True +) +class UserTypeV1(BaseObjectTypeV1): + groups: List[GroupTypeV1] diff --git a/netbox/virtualization/graphql/filter_mixins_v1.py b/netbox/virtualization/graphql/filter_mixins_v1.py new file mode 100644 index 000000000..7de4192c8 --- /dev/null +++ b/netbox/virtualization/graphql/filter_mixins_v1.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django +from strawberry import ID +from strawberry_django import FilterLookup + +from netbox.graphql.filter_mixins_v1 import NetBoxModelFilterMixinV1 + +if TYPE_CHECKING: + from .filters_v1 import VirtualMachineFilterV1 + +__all__ = ( + 'VMComponentFilterMixinV1', +) + + +@dataclass +class VMComponentFilterMixinV1(NetBoxModelFilterMixinV1): + virtual_machine: Annotated[ + 'VirtualMachineFilterV1', strawberry.lazy('virtualization.graphql.filters_v1') + ] | None = ( + strawberry_django.filter_field() + ) + virtual_machine_id: ID | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/virtualization/graphql/filters_v1.py b/netbox/virtualization/graphql/filters_v1.py new file mode 100644 index 000000000..d1ba0cff4 --- /dev/null +++ b/netbox/virtualization/graphql/filters_v1.py @@ -0,0 +1,170 @@ +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django +from strawberry.scalars import ID +from strawberry_django import FilterLookup + +from dcim.graphql.filter_mixins_v1 import InterfaceBaseFilterMixinV1, RenderConfigFilterMixinV1, ScopedFilterMixinV1 +from extras.graphql.filter_mixins_v1 import ConfigContextFilterMixinV1 +from netbox.graphql.filter_mixins_v1 import ( + ImageAttachmentFilterMixinV1, + OrganizationalModelFilterMixinV1, + PrimaryModelFilterMixinV1, +) +from tenancy.graphql.filter_mixins_v1 import ContactFilterMixinV1, TenancyFilterMixinV1 +from virtualization import models +from virtualization.graphql.filter_mixins_v1 import VMComponentFilterMixinV1 + +if TYPE_CHECKING: + from .enums import * + from netbox.graphql.filter_lookups import FloatLookup, IntegerLookup + from dcim.graphql.filters_v1 import ( + DeviceFilterV1, DeviceRoleFilterV1, MACAddressFilterV1, PlatformFilterV1, SiteFilterV1 + ) + from ipam.graphql.filters_v1 import ( + FHRPGroupAssignmentFilterV1, + IPAddressFilterV1, + ServiceFilterV1, + VLANGroupFilterV1, + VRFFilterV1, + ) + from vpn.graphql.filters_v1 import L2VPNFilterV1, TunnelTerminationFilterV1 + +__all__ = ( + 'ClusterFilterV1', + 'ClusterGroupFilterV1', + 'ClusterTypeFilterV1', + 'VirtualMachineFilterV1', + 'VMInterfaceFilterV1', + 'VirtualDiskFilterV1', +) + + +@strawberry_django.filter_type(models.Cluster, lookups=True) +class ClusterFilterV1(ContactFilterMixinV1, ScopedFilterMixinV1, TenancyFilterMixinV1, PrimaryModelFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + type: Annotated['ClusterTypeFilterV1', strawberry.lazy('virtualization.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + type_id: ID | None = strawberry_django.filter_field() + group: Annotated['ClusterGroupFilterV1', strawberry.lazy('virtualization.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + group_id: ID | None = strawberry_django.filter_field() + status: Annotated['ClusterStatusEnum', strawberry.lazy('virtualization.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + vlan_groups: Annotated['VLANGroupFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.ClusterGroup, lookups=True) +class ClusterGroupFilterV1(ContactFilterMixinV1, OrganizationalModelFilterMixinV1): + vlan_groups: Annotated['VLANGroupFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.ClusterType, lookups=True) +class ClusterTypeFilterV1(OrganizationalModelFilterMixinV1): + pass + + +@strawberry_django.filter_type(models.VirtualMachine, lookups=True) +class VirtualMachineFilterV1( + ContactFilterMixinV1, + ImageAttachmentFilterMixinV1, + RenderConfigFilterMixinV1, + ConfigContextFilterMixinV1, + TenancyFilterMixinV1, + PrimaryModelFilterMixinV1, +): + name: FilterLookup[str] | None = strawberry_django.filter_field() + site: Annotated['SiteFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + site_id: ID | None = strawberry_django.filter_field() + cluster: Annotated['ClusterFilterV1', strawberry.lazy('virtualization.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + cluster_id: ID | None = strawberry_django.filter_field() + device: Annotated['DeviceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + device_id: ID | None = strawberry_django.filter_field() + platform: Annotated['PlatformFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + platform_id: ID | None = strawberry_django.filter_field() + status: Annotated['VirtualMachineStatusEnum', strawberry.lazy('virtualization.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + role: Annotated['DeviceRoleFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + role_id: ID | None = strawberry_django.filter_field() + primary_ip4: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + primary_ip4_id: ID | None = strawberry_django.filter_field() + primary_ip6: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + primary_ip6_id: ID | None = strawberry_django.filter_field() + vcpus: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + memory: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + disk: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + serial: FilterLookup[str] | None = strawberry_django.filter_field() + interface_count: FilterLookup[int] | None = strawberry_django.filter_field() + virtual_disk_count: FilterLookup[int] | None = strawberry_django.filter_field() + interfaces: Annotated['VMInterfaceFilterV1', strawberry.lazy('virtualization.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + services: Annotated['ServiceFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + virtual_disks: Annotated['VirtualDiskFilterV1', strawberry.lazy('virtualization.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.VMInterface, lookups=True) +class VMInterfaceFilterV1(VMComponentFilterMixinV1, InterfaceBaseFilterMixinV1): + ip_addresses: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + vrf: Annotated['VRFFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = strawberry_django.filter_field() + vrf_id: ID | None = strawberry_django.filter_field() + parent: Annotated['VMInterfaceFilterV1', strawberry.lazy('virtualization.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + parent_id: ID | None = strawberry_django.filter_field() + fhrp_group_assignments: Annotated[ + 'FHRPGroupAssignmentFilterV1', strawberry.lazy('ipam.graphql.filters_v1') + ] | None = ( + strawberry_django.filter_field() + ) + tunnel_terminations: Annotated['TunnelTerminationFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + l2vpn_terminations: Annotated['L2VPNFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + mac_addresses: Annotated['MACAddressFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.VirtualDisk, lookups=True) +class VirtualDiskFilterV1(VMComponentFilterMixinV1): + size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) diff --git a/netbox/virtualization/graphql/schema_v1.py b/netbox/virtualization/graphql/schema_v1.py new file mode 100644 index 000000000..85994411f --- /dev/null +++ b/netbox/virtualization/graphql/schema_v1.py @@ -0,0 +1,27 @@ +from typing import List + +import strawberry +import strawberry_django + +from .types_v1 import * + + +@strawberry.type(name="Query") +class VirtualizationQueryV1: + cluster: ClusterTypeV1 = strawberry_django.field() + cluster_list: List[ClusterTypeV1] = strawberry_django.field() + + cluster_group: ClusterGroupTypeV1 = strawberry_django.field() + cluster_group_list: List[ClusterGroupTypeV1] = strawberry_django.field() + + cluster_type: ClusterTypeTypeV1 = strawberry_django.field() + cluster_type_list: List[ClusterTypeTypeV1] = strawberry_django.field() + + virtual_machine: VirtualMachineTypeV1 = strawberry_django.field() + virtual_machine_list: List[VirtualMachineTypeV1] = strawberry_django.field() + + vm_interface: VMInterfaceTypeV1 = strawberry_django.field() + vm_interface_list: List[VMInterfaceTypeV1] = strawberry_django.field() + + virtual_disk: VirtualDiskTypeV1 = strawberry_django.field() + virtual_disk_list: List[VirtualDiskTypeV1] = strawberry_django.field() diff --git a/netbox/virtualization/graphql/types_v1.py b/netbox/virtualization/graphql/types_v1.py new file mode 100644 index 000000000..079fa7b29 --- /dev/null +++ b/netbox/virtualization/graphql/types_v1.py @@ -0,0 +1,146 @@ +from typing import Annotated, List, TYPE_CHECKING, Union + +import strawberry +import strawberry_django + +from extras.graphql.mixins_v1 import ConfigContextMixinV1, ContactsMixinV1 +from ipam.graphql.mixins_v1 import IPAddressesMixinV1, VLANGroupsMixinV1 +from netbox.graphql.scalars import BigInt +from netbox.graphql.types_v1 import OrganizationalObjectTypeV1, NetBoxObjectTypeV1 +from virtualization import models +from .filters_v1 import * + +if TYPE_CHECKING: + from dcim.graphql.types_v1 import ( + DeviceRoleTypeV1, + DeviceTypeV1, + LocationTypeV1, + MACAddressTypeV1, + PlatformTypeV1, + RegionTypeV1, + SiteGroupTypeV1, + SiteTypeV1, + ) + from extras.graphql.types_v1 import ConfigTemplateTypeV1 + from ipam.graphql.types_v1 import IPAddressTypeV1, ServiceTypeV1, VLANTranslationPolicyTypeV1, VLANTypeV1, VRFTypeV1 + from tenancy.graphql.types_v1 import TenantTypeV1 + +__all__ = ( + 'ClusterTypeV1', + 'ClusterGroupTypeV1', + 'ClusterTypeTypeV1', + 'VirtualDiskTypeV1', + 'VirtualMachineTypeV1', + 'VMInterfaceTypeV1', +) + + +@strawberry.type +class ComponentTypeV1(NetBoxObjectTypeV1): + """ + Base type for device/VM components + """ + virtual_machine: Annotated["VirtualMachineTypeV1", strawberry.lazy('virtualization.graphql.types_v1')] + + +@strawberry_django.type( + models.Cluster, + exclude=['scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'], + filters=ClusterFilterV1, + pagination=True +) +class ClusterTypeV1(ContactsMixinV1, VLANGroupsMixinV1, NetBoxObjectTypeV1): + type: Annotated["ClusterTypeTypeV1", strawberry.lazy('virtualization.graphql.types_v1')] | None + group: Annotated["ClusterGroupTypeV1", strawberry.lazy('virtualization.graphql.types_v1')] | None + tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None + virtual_machines: List[Annotated["VirtualMachineTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] + devices: List[Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + @strawberry_django.field + def scope(self) -> Annotated[Union[ + Annotated["LocationTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["RegionTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["SiteGroupTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["SiteTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + ], strawberry.union("ClusterScopeTypeV1")] | None: + return self.scope + + +@strawberry_django.type( + models.ClusterGroup, + fields='__all__', + filters=ClusterGroupFilterV1, + pagination=True +) +class ClusterGroupTypeV1(ContactsMixinV1, VLANGroupsMixinV1, OrganizationalObjectTypeV1): + + clusters: List[Annotated["ClusterTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] + + +@strawberry_django.type( + models.ClusterType, + fields='__all__', + filters=ClusterTypeFilterV1, + pagination=True +) +class ClusterTypeTypeV1(OrganizationalObjectTypeV1): + + clusters: List[ClusterTypeV1] + + +@strawberry_django.type( + models.VirtualMachine, + fields='__all__', + filters=VirtualMachineFilterV1, + pagination=True +) +class VirtualMachineTypeV1(ConfigContextMixinV1, ContactsMixinV1, NetBoxObjectTypeV1): + interface_count: BigInt + virtual_disk_count: BigInt + interface_count: BigInt + config_template: Annotated["ConfigTemplateTypeV1", strawberry.lazy('extras.graphql.types_v1')] | None + site: Annotated["SiteTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + cluster: Annotated["ClusterTypeV1", strawberry.lazy('virtualization.graphql.types_v1')] | None + device: Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None + platform: Annotated["PlatformTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + role: Annotated["DeviceRoleTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + primary_ip4: Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + primary_ip6: Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + + interfaces: List[Annotated["VMInterfaceTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] + services: List[Annotated["ServiceTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] + virtualdisks: List[Annotated["VirtualDiskTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] + + +@strawberry_django.type( + models.VMInterface, + fields='__all__', + filters=VMInterfaceFilterV1, + pagination=True +) +class VMInterfaceTypeV1(IPAddressesMixinV1, ComponentTypeV1): + _name: str + mac_address: str | None + parent: Annotated["VMInterfaceTypeV1", strawberry.lazy('virtualization.graphql.types_v1')] | None + bridge: Annotated["VMInterfaceTypeV1", strawberry.lazy('virtualization.graphql.types_v1')] | None + untagged_vlan: Annotated["VLANTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + vrf: Annotated["VRFTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + primary_mac_address: Annotated["MACAddressTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + qinq_svlan: Annotated["VLANTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + vlan_translation_policy: Annotated["VLANTranslationPolicyTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + + tagged_vlans: List[Annotated["VLANTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] + bridge_interfaces: List[Annotated["VMInterfaceTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] + child_interfaces: List[Annotated["VMInterfaceTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] + mac_addresses: List[Annotated["MACAddressTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + +@strawberry_django.type( + models.VirtualDisk, + fields='__all__', + filters=VirtualDiskFilterV1, + pagination=True +) +class VirtualDiskTypeV1(ComponentTypeV1): + pass diff --git a/netbox/vpn/graphql/filters_v1.py b/netbox/vpn/graphql/filters_v1.py new file mode 100644 index 000000000..9b70b9a9e --- /dev/null +++ b/netbox/vpn/graphql/filters_v1.py @@ -0,0 +1,192 @@ +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django +from strawberry.scalars import ID +from strawberry_django import FilterLookup + +from core.graphql.filter_mixins_v1 import BaseObjectTypeFilterMixinV1, ChangeLogFilterMixinV1 +from extras.graphql.filter_mixins_v1 import CustomFieldsFilterMixinV1, TagsFilterMixinV1 +from netbox.graphql.filter_mixins_v1 import ( + NetBoxModelFilterMixinV1, OrganizationalModelFilterMixinV1, PrimaryModelFilterMixinV1 +) +from tenancy.graphql.filter_mixins_v1 import ContactFilterMixinV1, TenancyFilterMixinV1 +from vpn import models + +if TYPE_CHECKING: + from core.graphql.filters_v1 import ContentTypeFilterV1 + from ipam.graphql.filters_v1 import IPAddressFilterV1, RouteTargetFilterV1 + from netbox.graphql.filter_lookups import IntegerLookup + from .enums import * + +__all__ = ( + 'TunnelGroupFilterV1', + 'TunnelTerminationFilterV1', + 'TunnelFilterV1', + 'IKEProposalFilterV1', + 'IKEPolicyFilterV1', + 'IPSecProposalFilterV1', + 'IPSecPolicyFilterV1', + 'IPSecProfileFilterV1', + 'L2VPNFilterV1', + 'L2VPNTerminationFilterV1', +) + + +@strawberry_django.filter_type(models.TunnelGroup, lookups=True) +class TunnelGroupFilterV1(OrganizationalModelFilterMixinV1): + pass + + +@strawberry_django.filter_type(models.TunnelTermination, lookups=True) +class TunnelTerminationFilterV1( + BaseObjectTypeFilterMixinV1, CustomFieldsFilterMixinV1, TagsFilterMixinV1, ChangeLogFilterMixinV1 +): + tunnel: Annotated['TunnelFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + tunnel_id: ID | None = strawberry_django.filter_field() + role: Annotated['TunnelTerminationRoleEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + termination_type: Annotated['TunnelTerminationTypeEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + termination_type_id: ID | None = strawberry_django.filter_field() + termination_id: ID | None = strawberry_django.filter_field() + outside_ip: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + outside_ip_id: ID | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.Tunnel, lookups=True) +class TunnelFilterV1(TenancyFilterMixinV1, PrimaryModelFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + status: Annotated['TunnelStatusEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + group: Annotated['TunnelGroupFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + group_id: ID | None = strawberry_django.filter_field() + encapsulation: Annotated['TunnelEncapsulationEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + ipsec_profile: Annotated['IPSecProfileFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + tunnel_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + terminations: Annotated['TunnelTerminationFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.IKEProposal, lookups=True) +class IKEProposalFilterV1(PrimaryModelFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + authentication_method: Annotated['AuthenticationMethodEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + encryption_algorithm: Annotated['EncryptionAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + authentication_algorithm: Annotated['AuthenticationAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + group: Annotated['DHGroupEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() + sa_lifetime: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + ike_policies: Annotated['IKEPolicyFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.IKEPolicy, lookups=True) +class IKEPolicyFilterV1(PrimaryModelFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + version: Annotated['IKEVersionEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() + mode: Annotated['IKEModeEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() + proposals: Annotated['IKEProposalFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + preshared_key: FilterLookup[str] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.IPSecProposal, lookups=True) +class IPSecProposalFilterV1(PrimaryModelFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + encryption_algorithm: Annotated['EncryptionAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + authentication_algorithm: Annotated['AuthenticationAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + sa_lifetime_seconds: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + sa_lifetime_data: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + ipsec_policies: Annotated['IPSecPolicyFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.IPSecPolicy, lookups=True) +class IPSecPolicyFilterV1(PrimaryModelFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + proposals: Annotated['IPSecProposalFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + pfs_group: Annotated['DHGroupEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.IPSecProfile, lookups=True) +class IPSecProfileFilterV1(PrimaryModelFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + mode: Annotated['IPSecModeEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() + ike_policy: Annotated['IKEPolicyFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + ike_policy_id: ID | None = strawberry_django.filter_field() + ipsec_policy: Annotated['IPSecPolicyFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + ipsec_policy_id: ID | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.L2VPN, lookups=True) +class L2VPNFilterV1(ContactFilterMixinV1, TenancyFilterMixinV1, PrimaryModelFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() + type: Annotated['L2VPNTypeEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() + identifier: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + import_targets: Annotated['RouteTargetFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + export_targets: Annotated['RouteTargetFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + terminations: Annotated['L2VPNTerminationFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.L2VPNTermination, lookups=True) +class L2VPNTerminationFilterV1(NetBoxModelFilterMixinV1): + l2vpn: Annotated['L2VPNFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + l2vpn_id: ID | None = strawberry_django.filter_field() + assigned_object_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + assigned_object_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) diff --git a/netbox/vpn/graphql/schema_v1.py b/netbox/vpn/graphql/schema_v1.py new file mode 100644 index 000000000..7ed22333c --- /dev/null +++ b/netbox/vpn/graphql/schema_v1.py @@ -0,0 +1,39 @@ +from typing import List + +import strawberry +import strawberry_django + +from .types_v1 import * + + +@strawberry.type(name="Query") +class VPNQueryV1: + ike_policy: IKEPolicyTypeV1 = strawberry_django.field() + ike_policy_list: List[IKEPolicyTypeV1] = strawberry_django.field() + + ike_proposal: IKEProposalTypeV1 = strawberry_django.field() + ike_proposal_list: List[IKEProposalTypeV1] = strawberry_django.field() + + ipsec_policy: IPSecPolicyTypeV1 = strawberry_django.field() + ipsec_policy_list: List[IPSecPolicyTypeV1] = strawberry_django.field() + + ipsec_profile: IPSecProfileTypeV1 = strawberry_django.field() + ipsec_profile_list: List[IPSecProfileTypeV1] = strawberry_django.field() + + ipsec_proposal: IPSecProposalTypeV1 = strawberry_django.field() + ipsec_proposal_list: List[IPSecProposalTypeV1] = strawberry_django.field() + + l2vpn: L2VPNTypeV1 = strawberry_django.field() + l2vpn_list: List[L2VPNTypeV1] = strawberry_django.field() + + l2vpn_termination: L2VPNTerminationTypeV1 = strawberry_django.field() + l2vpn_termination_list: List[L2VPNTerminationTypeV1] = strawberry_django.field() + + tunnel: TunnelTypeV1 = strawberry_django.field() + tunnel_list: List[TunnelTypeV1] = strawberry_django.field() + + tunnel_group: TunnelGroupTypeV1 = strawberry_django.field() + tunnel_group_list: List[TunnelGroupTypeV1] = strawberry_django.field() + + tunnel_termination: TunnelTerminationTypeV1 = strawberry_django.field() + tunnel_termination_list: List[TunnelTerminationTypeV1] = strawberry_django.field() diff --git a/netbox/vpn/graphql/types_v1.py b/netbox/vpn/graphql/types_v1.py new file mode 100644 index 000000000..7812f88bb --- /dev/null +++ b/netbox/vpn/graphql/types_v1.py @@ -0,0 +1,157 @@ +from typing import Annotated, List, TYPE_CHECKING, Union + +import strawberry +import strawberry_django + +from extras.graphql.mixins_v1 import ContactsMixinV1, CustomFieldsMixinV1, TagsMixinV1 +from netbox.graphql.types_v1 import ObjectTypeV1, OrganizationalObjectTypeV1, NetBoxObjectTypeV1 +from vpn import models +from .filters_v1 import * + +if TYPE_CHECKING: + from dcim.graphql.types_v1 import InterfaceTypeV1 + from ipam.graphql.types_v1 import IPAddressTypeV1, RouteTargetTypeV1, VLANTypeV1 + from netbox.graphql.types_v1 import ContentTypeTypeV1 + from tenancy.graphql.types_v1 import TenantTypeV1 + from virtualization.graphql.types_v1 import VMInterfaceTypeV1 + +__all__ = ( + 'IKEPolicyTypeV1', + 'IKEProposalTypeV1', + 'IPSecPolicyTypeV1', + 'IPSecProfileTypeV1', + 'IPSecProposalTypeV1', + 'L2VPNTypeV1', + 'L2VPNTerminationTypeV1', + 'TunnelGroupTypeV1', + 'TunnelTerminationTypeV1', + 'TunnelTypeV1', +) + + +@strawberry_django.type( + models.TunnelGroup, + fields='__all__', + filters=TunnelGroupFilterV1, + pagination=True +) +class TunnelGroupTypeV1(ContactsMixinV1, OrganizationalObjectTypeV1): + + tunnels: List[Annotated["TunnelTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] + + +@strawberry_django.type( + models.TunnelTermination, + fields='__all__', + filters=TunnelTerminationFilterV1, + pagination=True +) +class TunnelTerminationTypeV1(CustomFieldsMixinV1, TagsMixinV1, ObjectTypeV1): + tunnel: Annotated["TunnelTypeV1", strawberry.lazy('vpn.graphql.types_v1')] + termination_type: Annotated["ContentTypeTypeV1", strawberry.lazy('netbox.graphql.types_v1')] | None + outside_ip: Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + + +@strawberry_django.type( + models.Tunnel, + fields='__all__', + filters=TunnelFilterV1, + pagination=True +) +class TunnelTypeV1(ContactsMixinV1, NetBoxObjectTypeV1): + group: Annotated["TunnelGroupTypeV1", strawberry.lazy('vpn.graphql.types_v1')] | None + ipsec_profile: Annotated["IPSecProfileTypeV1", strawberry.lazy('vpn.graphql.types_v1')] | None + tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None + + terminations: List[Annotated["TunnelTerminationTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] + + +@strawberry_django.type( + models.IKEProposal, + fields='__all__', + filters=IKEProposalFilterV1, + pagination=True +) +class IKEProposalTypeV1(OrganizationalObjectTypeV1): + + ike_policies: List[Annotated["IKEPolicyTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] + + +@strawberry_django.type( + models.IKEPolicy, + fields='__all__', + filters=IKEPolicyFilterV1, + pagination=True +) +class IKEPolicyTypeV1(OrganizationalObjectTypeV1): + + proposals: List[Annotated["IKEProposalTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] + ipsec_profiles: List[Annotated["IPSecProfileTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] + + +@strawberry_django.type( + models.IPSecProposal, + fields='__all__', + filters=IPSecProposalFilterV1, + pagination=True +) +class IPSecProposalTypeV1(OrganizationalObjectTypeV1): + + ipsec_policies: List[Annotated["IPSecPolicyTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] + + +@strawberry_django.type( + models.IPSecPolicy, + fields='__all__', + filters=IPSecPolicyFilterV1, + pagination=True +) +class IPSecPolicyTypeV1(OrganizationalObjectTypeV1): + + proposals: List[Annotated["IPSecProposalTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] + ipsec_profiles: List[Annotated["IPSecProfileTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] + + +@strawberry_django.type( + models.IPSecProfile, + fields='__all__', + filters=IPSecProfileFilterV1, + pagination=True +) +class IPSecProfileTypeV1(OrganizationalObjectTypeV1): + ike_policy: Annotated["IKEPolicyTypeV1", strawberry.lazy('vpn.graphql.types_v1')] + ipsec_policy: Annotated["IPSecPolicyTypeV1", strawberry.lazy('vpn.graphql.types_v1')] + + tunnels: List[Annotated["TunnelTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] + + +@strawberry_django.type( + models.L2VPN, + fields='__all__', + filters=L2VPNFilterV1, + pagination=True +) +class L2VPNTypeV1(ContactsMixinV1, NetBoxObjectTypeV1): + tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None + + export_targets: List[Annotated["RouteTargetTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] + terminations: List[Annotated["L2VPNTerminationTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] + import_targets: List[Annotated["RouteTargetTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] + + +@strawberry_django.type( + models.L2VPNTermination, + exclude=['assigned_object_type', 'assigned_object_id'], + filters=L2VPNTerminationFilterV1, + pagination=True +) +class L2VPNTerminationTypeV1(NetBoxObjectTypeV1): + l2vpn: Annotated["L2VPNTypeV1", strawberry.lazy('vpn.graphql.types_v1')] + + @strawberry_django.field + def assigned_object(self) -> Annotated[Union[ + Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["VLANTypeV1", strawberry.lazy('ipam.graphql.types_v1')], + Annotated["VMInterfaceTypeV1", strawberry.lazy('virtualization.graphql.types_v1')], + ], strawberry.union("L2VPNAssignmentTypeV1")]: + return self.assigned_object diff --git a/netbox/wireless/graphql/filter_mixins_v1.py b/netbox/wireless/graphql/filter_mixins_v1.py new file mode 100644 index 000000000..2e32f2cde --- /dev/null +++ b/netbox/wireless/graphql/filter_mixins_v1.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django +from strawberry_django import FilterLookup + +from core.graphql.filter_mixins_v1 import BaseFilterMixinV1 + +if TYPE_CHECKING: + from .enums import * + +__all__ = ( + 'WirelessAuthenticationBaseFilterMixinV1', +) + + +@dataclass +class WirelessAuthenticationBaseFilterMixinV1(BaseFilterMixinV1): + auth_type: Annotated['WirelessAuthTypeEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + auth_cipher: Annotated['WirelessAuthCipherEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + auth_psk: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/wireless/graphql/filters_v1.py b/netbox/wireless/graphql/filters_v1.py new file mode 100644 index 000000000..166679843 --- /dev/null +++ b/netbox/wireless/graphql/filters_v1.py @@ -0,0 +1,72 @@ +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django +from strawberry.scalars import ID +from strawberry_django import FilterLookup + +from dcim.graphql.filter_mixins_v1 import ScopedFilterMixinV1 +from netbox.graphql.filter_mixins_v1 import ( + DistanceFilterMixinV1, PrimaryModelFilterMixinV1, NestedGroupModelFilterMixinV1 +) +from tenancy.graphql.filter_mixins_v1 import TenancyFilterMixinV1 +from wireless import models +from .filter_mixins_v1 import WirelessAuthenticationBaseFilterMixinV1 + +if TYPE_CHECKING: + from dcim.graphql.filters_v1 import InterfaceFilterV1 + from ipam.graphql.filters_v1 import VLANFilterV1 + from .enums import * + +__all__ = ( + 'WirelessLANGroupFilterV1', + 'WirelessLANFilterV1', + 'WirelessLinkFilterV1', +) + + +@strawberry_django.filter_type(models.WirelessLANGroup, lookups=True) +class WirelessLANGroupFilterV1(NestedGroupModelFilterMixinV1): + pass + + +@strawberry_django.filter_type(models.WirelessLAN, lookups=True) +class WirelessLANFilterV1( + WirelessAuthenticationBaseFilterMixinV1, + ScopedFilterMixinV1, + TenancyFilterMixinV1, + PrimaryModelFilterMixinV1 +): + ssid: FilterLookup[str] | None = strawberry_django.filter_field() + status: Annotated['WirelessLANStatusEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + group: Annotated['WirelessLANGroupFilterV1', strawberry.lazy('wireless.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + group_id: ID | None = strawberry_django.filter_field() + vlan: Annotated['VLANFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + vlan_id: ID | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.WirelessLink, lookups=True) +class WirelessLinkFilterV1( + WirelessAuthenticationBaseFilterMixinV1, + DistanceFilterMixinV1, + TenancyFilterMixinV1, + PrimaryModelFilterMixinV1 +): + interface_a: Annotated['InterfaceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + interface_a_id: ID | None = strawberry_django.filter_field() + interface_b: Annotated['InterfaceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + interface_b_id: ID | None = strawberry_django.filter_field() + ssid: FilterLookup[str] | None = strawberry_django.filter_field() + status: Annotated['WirelessLANStatusEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) diff --git a/netbox/wireless/graphql/schema_v1.py b/netbox/wireless/graphql/schema_v1.py new file mode 100644 index 000000000..fafe28c08 --- /dev/null +++ b/netbox/wireless/graphql/schema_v1.py @@ -0,0 +1,18 @@ +from typing import List + +import strawberry +import strawberry_django + +from .types_v1 import * + + +@strawberry.type(name="Query") +class WirelessQueryV1: + wireless_lan: WirelessLANTypeV1 = strawberry_django.field() + wireless_lan_list: List[WirelessLANTypeV1] = strawberry_django.field() + + wireless_lan_group: WirelessLANGroupTypeV1 = strawberry_django.field() + wireless_lan_group_list: List[WirelessLANGroupTypeV1] = strawberry_django.field() + + wireless_link: WirelessLinkTypeV1 = strawberry_django.field() + wireless_link_list: List[WirelessLinkTypeV1] = strawberry_django.field() diff --git a/netbox/wireless/graphql/types_v1.py b/netbox/wireless/graphql/types_v1.py new file mode 100644 index 000000000..b774f8f28 --- /dev/null +++ b/netbox/wireless/graphql/types_v1.py @@ -0,0 +1,71 @@ +from typing import Annotated, List, TYPE_CHECKING, Union + +import strawberry +import strawberry_django + +from netbox.graphql.types_v1 import OrganizationalObjectTypeV1, NetBoxObjectTypeV1 +from wireless import models +from .filters_v1 import * + +if TYPE_CHECKING: + from dcim.graphql.types_v1 import ( + DeviceTypeV1, InterfaceTypeV1, LocationTypeV1, RegionTypeV1, SiteGroupTypeV1, SiteTypeV1 + ) + from ipam.graphql.types_v1 import VLANTypeV1 + from tenancy.graphql.types_v1 import TenantTypeV1 + +__all__ = ( + 'WirelessLANTypeV1', + 'WirelessLANGroupTypeV1', + 'WirelessLinkTypeV1', +) + + +@strawberry_django.type( + models.WirelessLANGroup, + fields='__all__', + filters=WirelessLANGroupFilterV1, + pagination=True +) +class WirelessLANGroupTypeV1(OrganizationalObjectTypeV1): + parent: Annotated["WirelessLANGroupTypeV1", strawberry.lazy('wireless.graphql.types_v1')] | None + + wireless_lans: List[Annotated["WirelessLANTypeV1", strawberry.lazy('wireless.graphql.types_v1')]] + children: List[Annotated["WirelessLANGroupTypeV1", strawberry.lazy('wireless.graphql.types_v1')]] + + +@strawberry_django.type( + models.WirelessLAN, + exclude=['scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'], + filters=WirelessLANFilterV1, + pagination=True +) +class WirelessLANTypeV1(NetBoxObjectTypeV1): + group: Annotated["WirelessLANGroupTypeV1", strawberry.lazy('wireless.graphql.types_v1')] | None + vlan: Annotated["VLANTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None + tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None + + interfaces: List[Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] + + @strawberry_django.field + def scope(self) -> Annotated[Union[ + Annotated["LocationTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["RegionTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["SiteGroupTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + Annotated["SiteTypeV1", strawberry.lazy('dcim.graphql.types_v1')], + ], strawberry.union("WirelessLANScopeTypeV1")] | None: + return self.scope + + +@strawberry_django.type( + models.WirelessLink, + fields='__all__', + filters=WirelessLinkFilterV1, + pagination=True +) +class WirelessLinkTypeV1(NetBoxObjectTypeV1): + interface_a: Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] + interface_b: Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] + tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None + _interface_a_device: Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + _interface_b_device: Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None From a718cb1173c5da5425f294c5d1a20882b0e1950c Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Fri, 24 Oct 2025 14:33:34 -0400 Subject: [PATCH 083/125] Convert all id fields and enum fields to FilterLookups (with in_list and exact support) --- netbox/circuits/graphql/filters.py | 14 ++- netbox/core/graphql/filter_mixins.py | 6 +- netbox/dcim/graphql/filters.py | 152 ++++++++++++++--------- netbox/extras/graphql/filters.py | 30 +++-- netbox/ipam/graphql/filters.py | 20 +-- netbox/tenancy/graphql/filters.py | 2 +- netbox/virtualization/graphql/filters.py | 6 +- netbox/vpn/graphql/filters.py | 54 +++++--- netbox/wireless/graphql/filters.py | 4 +- 9 files changed, 183 insertions(+), 105 deletions(-) diff --git a/netbox/circuits/graphql/filters.py b/netbox/circuits/graphql/filters.py index d6ef2976d..545e76cb8 100644 --- a/netbox/circuits/graphql/filters.py +++ b/netbox/circuits/graphql/filters.py @@ -52,7 +52,9 @@ class CircuitTerminationFilter( circuit: Annotated['CircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( strawberry_django.filter_field() ) - term_side: Annotated['CircuitTerminationSideEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( + term_side: ( + FilterLookup[Annotated['CircuitTerminationSideEnum', strawberry.lazy('circuits.graphql.enums')]] | None + ) = ( strawberry_django.filter_field() ) termination_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( @@ -108,7 +110,7 @@ class CircuitFilter( strawberry_django.filter_field() ) type_id: ID | None = strawberry_django.filter_field() - status: Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( + status: FilterLookup[Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')]] | None = ( strawberry_django.filter_field() ) install_date: DateFilterLookup[date] | None = strawberry_django.filter_field() @@ -143,7 +145,7 @@ class CircuitGroupAssignmentFilter( strawberry_django.filter_field() ) group_id: ID | None = strawberry_django.filter_field() - priority: Annotated['CircuitPriorityEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( + priority: FilterLookup[Annotated['CircuitPriorityEnum', strawberry.lazy('circuits.graphql.enums')]] | None = ( strawberry_django.filter_field() ) @@ -198,7 +200,7 @@ class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilterMixin): strawberry_django.filter_field() ) type_id: ID | None = strawberry_django.filter_field() - status: Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( + status: FilterLookup[Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')]] | None = ( strawberry_django.filter_field() ) group_assignments: Annotated['CircuitGroupAssignmentFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( @@ -214,7 +216,9 @@ class VirtualCircuitTerminationFilter( strawberry_django.filter_field() ) virtual_circuit_id: ID | None = strawberry_django.filter_field() - role: Annotated['VirtualCircuitTerminationRoleEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( + role: ( + FilterLookup[Annotated['VirtualCircuitTerminationRoleEnum', strawberry.lazy('circuits.graphql.enums')]] | None + ) = ( strawberry_django.filter_field() ) interface: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( diff --git a/netbox/core/graphql/filter_mixins.py b/netbox/core/graphql/filter_mixins.py index 670ec2ebb..27d950d73 100644 --- a/netbox/core/graphql/filter_mixins.py +++ b/netbox/core/graphql/filter_mixins.py @@ -5,7 +5,7 @@ from typing import Annotated, TYPE_CHECKING import strawberry import strawberry_django from strawberry import ID -from strawberry_django import DatetimeFilterLookup +from strawberry_django import FilterLookup, DatetimeFilterLookup if TYPE_CHECKING: from .filters import * @@ -23,12 +23,12 @@ class BaseFilterMixin: ... @dataclass class BaseObjectTypeFilterMixin(BaseFilterMixin): - id: ID | None = strawberry.UNSET + id: FilterLookup[ID] | None = strawberry_django.filter_field() @dataclass class ChangeLogFilterMixin(BaseFilterMixin): - id: ID | None = strawberry.UNSET + id: FilterLookup[ID] | None = strawberry_django.filter_field() changelog: Annotated['ObjectChangeFilter', strawberry.lazy('core.graphql.filters')] | None = ( strawberry_django.filter_field() ) diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py index 111902dd9..4ccb85580 100644 --- a/netbox/dcim/graphql/filters.py +++ b/netbox/dcim/graphql/filters.py @@ -97,14 +97,20 @@ __all__ = ( @strawberry_django.filter_type(models.Cable, lookups=True) class CableFilter(PrimaryModelFilterMixin, TenancyFilterMixin): - type: Annotated['CableTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() - status: Annotated['LinkStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + type: FilterLookup[Annotated['CableTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) + status: FilterLookup[Annotated['LinkStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) label: FilterLookup[str] | None = strawberry_django.filter_field() - color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + color: FilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) length: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() ) - length_unit: Annotated['CableLengthUnitEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + length_unit: FilterLookup[Annotated['CableLengthUnitEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) terminations: Annotated['CableTerminationFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( @@ -116,7 +122,7 @@ class CableFilter(PrimaryModelFilterMixin, TenancyFilterMixin): class CableTerminationFilter(ChangeLogFilterMixin): cable: Annotated['CableFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() cable_id: ID | None = strawberry_django.filter_field() - cable_end: Annotated['CableEndEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + cable_end: FilterLookup[Annotated['CableEndEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) termination_type: Annotated['CableTerminationFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( @@ -127,34 +133,34 @@ class CableTerminationFilter(ChangeLogFilterMixin): @strawberry_django.filter_type(models.ConsolePort, lookups=True) class ConsolePortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): - type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + type: FilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - speed: Annotated['ConsolePortSpeedEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + speed: FilterLookup[Annotated['ConsolePortSpeedEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) @strawberry_django.filter_type(models.ConsolePortTemplate, lookups=True) class ConsolePortTemplateFilter(ModularComponentTemplateFilterMixin): - type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + type: FilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) @strawberry_django.filter_type(models.ConsoleServerPort, lookups=True) class ConsoleServerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): - type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + type: FilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - speed: Annotated['ConsolePortSpeedEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + speed: FilterLookup[Annotated['ConsolePortSpeedEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) @strawberry_django.filter_type(models.ConsoleServerPortTemplate, lookups=True) class ConsoleServerPortTemplateFilter(ModularComponentTemplateFilterMixin): - type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + type: FilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) @@ -195,11 +201,13 @@ class DeviceFilter( position: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() ) - face: Annotated['DeviceFaceEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() - status: Annotated['DeviceStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + face: FilterLookup[Annotated['DeviceFaceEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - airflow: Annotated['DeviceAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + status: FilterLookup[Annotated['DeviceStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) + airflow: FilterLookup[Annotated['DeviceAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) primary_ip4: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( @@ -311,7 +319,9 @@ class InventoryItemTemplateFilter(ComponentTemplateFilterMixin): @strawberry_django.filter_type(models.DeviceRole, lookups=True) class DeviceRoleFilter(OrganizationalModelFilterMixin, RenderConfigFilterMixin): - color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + color: FilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) vm_role: FilterLookup[bool] | None = strawberry_django.filter_field() @@ -336,10 +346,10 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig ) exclude_from_utilization: FilterLookup[bool] | None = strawberry_django.filter_field() is_full_depth: FilterLookup[bool] | None = strawberry_django.filter_field() - subdevice_role: Annotated['SubdeviceRoleEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + subdevice_role: FilterLookup[Annotated['SubdeviceRoleEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - airflow: Annotated['DeviceAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + airflow: FilterLookup[Annotated['DeviceAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) front_image: Annotated['ImageAttachmentFilter', strawberry.lazy('extras.graphql.filters')] | None = ( @@ -393,8 +403,12 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig @strawberry_django.filter_type(models.FrontPort, lookups=True) class FrontPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): - type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() - color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + type: FilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) + color: FilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) rear_port: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() ) @@ -406,8 +420,12 @@ class FrontPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterM @strawberry_django.filter_type(models.FrontPortTemplate, lookups=True) class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin): - type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() - color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + type: FilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) + color: FilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) rear_port: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() ) @@ -451,14 +469,14 @@ class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin ) lag: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() lag_id: ID | None = strawberry_django.filter_field() - type: Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + type: FilterLookup[Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) mgmt_only: FilterLookup[bool] | None = strawberry_django.filter_field() speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() ) - duplex: Annotated['InterfaceDuplexEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + duplex: FilterLookup[Annotated['InterfaceDuplexEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) wwn: FilterLookup[str] | None = strawberry_django.filter_field() @@ -466,10 +484,10 @@ class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin strawberry_django.filter_field() ) parent_id: ID | None = strawberry_django.filter_field() - rf_role: Annotated['WirelessRoleEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + rf_role: FilterLookup[Annotated['WirelessRoleEnum', strawberry.lazy('wireless.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - rf_channel: Annotated['WirelessChannelEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + rf_channel: FilterLookup[Annotated['WirelessChannelEnum', strawberry.lazy('wireless.graphql.enums')]] | None = ( strawberry_django.filter_field() ) rf_channel_frequency: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( @@ -481,10 +499,10 @@ class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin tx_power: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() ) - poe_mode: Annotated['InterfacePoEModeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + poe_mode: FilterLookup[Annotated['InterfacePoEModeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - poe_type: Annotated['InterfacePoETypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + poe_type: FilterLookup[Annotated['InterfacePoETypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) wireless_link: Annotated['WirelessLinkFilter', strawberry.lazy('wireless.graphql.filters')] | None = ( @@ -536,7 +554,7 @@ class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin @strawberry_django.filter_type(models.InterfaceTemplate, lookups=True) class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin): - type: Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + type: FilterLookup[Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) enabled: FilterLookup[bool] | None = strawberry_django.filter_field() @@ -545,13 +563,13 @@ class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin): strawberry_django.filter_field() ) bridge_id: ID | None = strawberry_django.filter_field() - poe_mode: Annotated['InterfacePoEModeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + poe_mode: FilterLookup[Annotated['InterfacePoEModeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - poe_type: Annotated['InterfacePoETypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + poe_type: FilterLookup[Annotated['InterfacePoETypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - rf_role: Annotated['WirelessRoleEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + rf_role: FilterLookup[Annotated['WirelessRoleEnum', strawberry.lazy('wireless.graphql.enums')]] | None = ( strawberry_django.filter_field() ) @@ -566,7 +584,7 @@ class InventoryItemFilter(ComponentModelFilterMixin): strawberry_django.filter_field() ) component_id: ID | None = strawberry_django.filter_field() - status: Annotated['InventoryItemStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + status: FilterLookup[Annotated['InventoryItemStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) role: Annotated['InventoryItemRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( @@ -585,14 +603,16 @@ class InventoryItemFilter(ComponentModelFilterMixin): @strawberry_django.filter_type(models.InventoryItemRole, lookups=True) class InventoryItemRoleFilter(OrganizationalModelFilterMixin): - color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + color: FilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter_type(models.Location, lookups=True) class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, NestedGroupModelFilterMixin): site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() site_id: ID | None = strawberry_django.filter_field() - status: Annotated['LocationStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + status: FilterLookup[Annotated['LocationStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) facility: FilterLookup[str] | None = strawberry_django.filter_field() @@ -621,7 +641,7 @@ class ModuleFilter(PrimaryModelFilterMixin, ConfigContextFilterMixin): strawberry_django.filter_field() ) module_type_id: ID | None = strawberry_django.filter_field() - status: Annotated['ModuleStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + status: FilterLookup[Annotated['ModuleStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) serial: FilterLookup[str] | None = strawberry_django.filter_field() @@ -692,7 +712,7 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig instances: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() ) - airflow: Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + airflow: FilterLookup[Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) console_port_templates: ( @@ -749,16 +769,16 @@ class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryM rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() rack_id: ID | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field() - status: Annotated['PowerFeedStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + status: FilterLookup[Annotated['PowerFeedStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - type: Annotated['PowerFeedTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + type: FilterLookup[Annotated['PowerFeedTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - supply: Annotated['PowerFeedSupplyEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + supply: FilterLookup[Annotated['PowerFeedSupplyEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - phase: Annotated['PowerFeedPhaseEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + phase: FilterLookup[Annotated['PowerFeedPhaseEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) voltage: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( @@ -777,29 +797,31 @@ class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryM @strawberry_django.filter_type(models.PowerOutlet, lookups=True) class PowerOutletFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): - type: Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + type: FilterLookup[Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) power_port: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() ) power_port_id: ID | None = strawberry_django.filter_field() - feed_leg: Annotated['PowerOutletFeedLegEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + feed_leg: FilterLookup[Annotated['PowerOutletFeedLegEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) + color: FilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() @strawberry_django.filter_type(models.PowerOutletTemplate, lookups=True) class PowerOutletTemplateFilter(ModularComponentModelFilterMixin): - type: Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + type: FilterLookup[Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) power_port: Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() ) power_port_id: ID | None = strawberry_django.filter_field() - feed_leg: Annotated['PowerOutletFeedLegEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + feed_leg: FilterLookup[Annotated['PowerOutletFeedLegEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) @@ -819,7 +841,7 @@ class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryMo @strawberry_django.filter_type(models.PowerPort, lookups=True) class PowerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): - type: Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + type: FilterLookup[Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) maximum_draw: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( @@ -832,7 +854,7 @@ class PowerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterM @strawberry_django.filter_type(models.PowerPortTemplate, lookups=True) class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin): - type: Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + type: FilterLookup[Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) maximum_draw: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( @@ -845,7 +867,7 @@ class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin): @strawberry_django.filter_type(models.RackType, lookups=True) class RackTypeFilter(RackBaseFilterMixin): - form_factor: Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + form_factor: FilterLookup[Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( @@ -860,7 +882,7 @@ class RackTypeFilter(RackBaseFilterMixin): @strawberry_django.filter_type(models.Rack, lookups=True) class RackFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, RackBaseFilterMixin): - form_factor: Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + form_factor: FilterLookup[Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) rack_type: Annotated['RackTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( @@ -877,12 +899,14 @@ class RackFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMi location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() ) - status: Annotated['RackStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + status: FilterLookup[Annotated['RackStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) role: Annotated['RackRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() role_id: ID | None = strawberry_django.filter_field() serial: FilterLookup[str] | None = strawberry_django.filter_field() asset_tag: FilterLookup[str] | None = strawberry_django.filter_field() - airflow: Annotated['RackAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + airflow: FilterLookup[Annotated['RackAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( @@ -904,13 +928,19 @@ class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilterMixin): @strawberry_django.filter_type(models.RackRole, lookups=True) class RackRoleFilter(OrganizationalModelFilterMixin): - color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + color: FilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter_type(models.RearPort, lookups=True) class RearPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): - type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() - color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + type: FilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) + color: FilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) positions: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() ) @@ -918,8 +948,12 @@ class RearPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMi @strawberry_django.filter_type(models.RearPortTemplate, lookups=True) class RearPortTemplateFilter(ModularComponentTemplateFilterMixin): - type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() - color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + type: FilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) + color: FilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) positions: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() ) @@ -939,7 +973,9 @@ class RegionFilter(ContactFilterMixin, NestedGroupModelFilterMixin): class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin): name: FilterLookup[str] | None = strawberry_django.filter_field() slug: FilterLookup[str] | None = strawberry_django.filter_field() - status: Annotated['SiteStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + status: FilterLookup[Annotated['SiteStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) region: Annotated['RegionFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() region_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() @@ -996,7 +1032,7 @@ class VirtualDeviceContextFilter(TenancyFilterMixin, PrimaryModelFilterMixin): device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() device_id: ID | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field() - status: Annotated['VirtualDeviceContextStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + status: FilterLookup[Annotated['VirtualDeviceContextStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) identifier: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( diff --git a/netbox/extras/graphql/filters.py b/netbox/extras/graphql/filters.py index 13221982c..75aaac955 100644 --- a/netbox/extras/graphql/filters.py +++ b/netbox/extras/graphql/filters.py @@ -121,7 +121,7 @@ class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Cha @strawberry_django.filter_type(models.CustomField, lookups=True) class CustomFieldFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): - type: Annotated['CustomFieldTypeEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + type: FilterLookup[Annotated['CustomFieldTypeEnum', strawberry.lazy('extras.graphql.enums')]] | None = ( strawberry_django.filter_field() ) object_types: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( @@ -139,7 +139,9 @@ class CustomFieldFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): search_weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() ) - filter_logic: Annotated['CustomFieldFilterLogicEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + filter_logic: ( + FilterLookup[Annotated['CustomFieldFilterLogicEnum', strawberry.lazy('extras.graphql.enums')]] | None + ) = ( strawberry_django.filter_field() ) default: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( @@ -162,10 +164,12 @@ class CustomFieldFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): strawberry_django.filter_field() ) choice_set_id: ID | None = strawberry_django.filter_field() - ui_visible: Annotated['CustomFieldUIVisibleEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + ui_visible: FilterLookup[Annotated['CustomFieldUIVisibleEnum', strawberry.lazy('extras.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - ui_editable: Annotated['CustomFieldUIEditableEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + ui_editable: ( + FilterLookup[Annotated['CustomFieldUIEditableEnum', strawberry.lazy('extras.graphql.enums')]] | None + ) = ( strawberry_django.filter_field() ) is_cloneable: FilterLookup[bool] | None = strawberry_django.filter_field() @@ -176,7 +180,9 @@ class CustomFieldFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): class CustomFieldChoiceSetFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): name: FilterLookup[str] | None = strawberry_django.filter_field() description: FilterLookup[str] | None = strawberry_django.filter_field() - base_choices: Annotated['CustomFieldChoiceSetBaseEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + base_choices: ( + FilterLookup[Annotated['CustomFieldChoiceSetBaseEnum', strawberry.lazy('extras.graphql.enums')]] | None + ) = ( strawberry_django.filter_field() ) extra_choices: Annotated['StringArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( @@ -195,7 +201,9 @@ class CustomLinkFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): strawberry_django.filter_field() ) group_name: FilterLookup[str] | None = strawberry_django.filter_field() - button_class: Annotated['CustomLinkButtonClassEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + button_class: ( + FilterLookup[Annotated['CustomLinkButtonClassEnum', strawberry.lazy('extras.graphql.enums')]] | None + ) = ( strawberry_django.filter_field() ) new_window: FilterLookup[bool] | None = strawberry_django.filter_field() @@ -240,7 +248,7 @@ class JournalEntryFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, Tag created_by: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = ( strawberry_django.filter_field() ) - kind: Annotated['JournalEntryKindEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + kind: FilterLookup[Annotated['JournalEntryKindEnum', strawberry.lazy('extras.graphql.enums')]] | None = ( strawberry_django.filter_field() ) comments: FilterLookup[str] | None = strawberry_django.filter_field() @@ -286,7 +294,9 @@ class TableConfigFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): @strawberry_django.filter_type(models.Tag, lookups=True) class TagFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin, TagBaseFilterMixin): - color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + color: FilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) description: FilterLookup[str] | None = strawberry_django.filter_field() @@ -295,7 +305,7 @@ class WebhookFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilt name: FilterLookup[str] | None = strawberry_django.filter_field() description: FilterLookup[str] | None = strawberry_django.filter_field() payload_url: FilterLookup[str] | None = strawberry_django.filter_field() - http_method: Annotated['WebhookHttpMethodEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + http_method: FilterLookup[Annotated['WebhookHttpMethodEnum', strawberry.lazy('extras.graphql.enums')]] | None = ( strawberry_django.filter_field() ) http_content_type: FilterLookup[str] | None = strawberry_django.filter_field() @@ -320,7 +330,7 @@ class EventRuleFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFi conditions: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() ) - action_type: Annotated['EventRuleActionEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + action_type: FilterLookup[Annotated['EventRuleActionEnum', strawberry.lazy('extras.graphql.enums')]] | None = ( strawberry_django.filter_field() ) action_object_type: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/ipam/graphql/filters.py b/netbox/ipam/graphql/filters.py index c0a01d72b..ce22c16ed 100644 --- a/netbox/ipam/graphql/filters.py +++ b/netbox/ipam/graphql/filters.py @@ -116,10 +116,10 @@ class FHRPGroupFilter(PrimaryModelFilterMixin): strawberry_django.filter_field() ) name: FilterLookup[str] | None = strawberry_django.filter_field() - protocol: Annotated['FHRPGroupProtocolEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( + protocol: FilterLookup[Annotated['FHRPGroupProtocolEnum', strawberry.lazy('ipam.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - auth_type: Annotated['FHRPGroupAuthTypeEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( + auth_type: FilterLookup[Annotated['FHRPGroupAuthTypeEnum', strawberry.lazy('ipam.graphql.enums')]] | None = ( strawberry_django.filter_field() ) auth_key: FilterLookup[str] | None = strawberry_django.filter_field() @@ -172,10 +172,10 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter address: FilterLookup[str] | None = strawberry_django.filter_field() vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() vrf_id: ID | None = strawberry_django.filter_field() - status: Annotated['IPAddressStatusEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( + status: FilterLookup[Annotated['IPAddressStatusEnum', strawberry.lazy('ipam.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - role: Annotated['IPAddressRoleEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( + role: FilterLookup[Annotated['IPAddressRoleEnum', strawberry.lazy('ipam.graphql.enums')]] | None = ( strawberry_django.filter_field() ) assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( @@ -212,7 +212,7 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter @strawberry_django.filter_field() def family( self, - value: Annotated['IPAddressFamilyEnum', strawberry.lazy('ipam.graphql.enums')], + value: FilterLookup[Annotated['IPAddressFamilyEnum', strawberry.lazy('ipam.graphql.enums')]], prefix, ) -> Q: return Q(**{f"{prefix}address__family": value.value}) @@ -227,7 +227,7 @@ class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMi ) vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() vrf_id: ID | None = strawberry_django.filter_field() - status: Annotated['IPRangeStatusEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( + status: FilterLookup[Annotated['IPRangeStatusEnum', strawberry.lazy('ipam.graphql.enums')]] | None = ( strawberry_django.filter_field() ) role: Annotated['RoleFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() @@ -279,7 +279,7 @@ class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, Pr vrf_id: ID | None = strawberry_django.filter_field() vlan: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() vlan_id: ID | None = strawberry_django.filter_field() - status: Annotated['PrefixStatusEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( + status: FilterLookup[Annotated['PrefixStatusEnum', strawberry.lazy('ipam.graphql.enums')]] | None = ( strawberry_django.filter_field() ) role: Annotated['RoleFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() @@ -367,7 +367,9 @@ class VLANFilter(TenancyFilterMixin, PrimaryModelFilterMixin): strawberry_django.filter_field() ) name: FilterLookup[str] | None = strawberry_django.filter_field() - status: Annotated['VLANStatusEnum', strawberry.lazy('ipam.graphql.enums')] | None = strawberry_django.filter_field() + status: FilterLookup[Annotated['VLANStatusEnum', strawberry.lazy('ipam.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) role: Annotated['RoleFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() role_id: ID | None = strawberry_django.filter_field() qinq_svlan: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( @@ -377,7 +379,7 @@ class VLANFilter(TenancyFilterMixin, PrimaryModelFilterMixin): qinq_cvlans: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( strawberry_django.filter_field() ) - qinq_role: Annotated['VLANQinQRoleEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( + qinq_role: FilterLookup[Annotated['VLANQinQRoleEnum', strawberry.lazy('ipam.graphql.enums')]] | None = ( strawberry_django.filter_field() ) l2vpn_terminations: Annotated['L2VPNFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( diff --git a/netbox/tenancy/graphql/filters.py b/netbox/tenancy/graphql/filters.py index fb37359ef..08682c442 100644 --- a/netbox/tenancy/graphql/filters.py +++ b/netbox/tenancy/graphql/filters.py @@ -191,6 +191,6 @@ class ContactAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLo strawberry_django.filter_field() ) role_id: ID | None = strawberry_django.filter_field() - priority: Annotated['ContactPriorityEnum', strawberry.lazy('tenancy.graphql.enums')] | None = ( + priority: FilterLookup[Annotated['ContactPriorityEnum', strawberry.lazy('tenancy.graphql.enums')]] | None = ( strawberry_django.filter_field() ) diff --git a/netbox/virtualization/graphql/filters.py b/netbox/virtualization/graphql/filters.py index a10ade5a2..7ee8d9053 100644 --- a/netbox/virtualization/graphql/filters.py +++ b/netbox/virtualization/graphql/filters.py @@ -50,7 +50,7 @@ class ClusterFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, P strawberry_django.filter_field() ) group_id: ID | None = strawberry_django.filter_field() - status: Annotated['ClusterStatusEnum', strawberry.lazy('virtualization.graphql.enums')] | None = ( + status: FilterLookup[Annotated['ClusterStatusEnum', strawberry.lazy('virtualization.graphql.enums')]] | None = ( strawberry_django.filter_field() ) vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( @@ -92,7 +92,9 @@ class VirtualMachineFilter( strawberry_django.filter_field() ) platform_id: ID | None = strawberry_django.filter_field() - status: Annotated['VirtualMachineStatusEnum', strawberry.lazy('virtualization.graphql.enums')] | None = ( + status: ( + FilterLookup[Annotated['VirtualMachineStatusEnum', strawberry.lazy('virtualization.graphql.enums')]] | None + ) = ( strawberry_django.filter_field() ) role: Annotated['DeviceRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( diff --git a/netbox/vpn/graphql/filters.py b/netbox/vpn/graphql/filters.py index 21adcd100..d7bd423ef 100644 --- a/netbox/vpn/graphql/filters.py +++ b/netbox/vpn/graphql/filters.py @@ -42,10 +42,12 @@ class TunnelTerminationFilter( ): tunnel: Annotated['TunnelFilter', strawberry.lazy('vpn.graphql.filters')] | None = strawberry_django.filter_field() tunnel_id: ID | None = strawberry_django.filter_field() - role: Annotated['TunnelTerminationRoleEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + role: FilterLookup[Annotated['TunnelTerminationRoleEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - termination_type: Annotated['TunnelTerminationTypeEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + termination_type: ( + FilterLookup[Annotated['TunnelTerminationTypeEnum', strawberry.lazy('vpn.graphql.enums')]] | None + ) = ( strawberry_django.filter_field() ) termination_type_id: ID | None = strawberry_django.filter_field() @@ -59,14 +61,14 @@ class TunnelTerminationFilter( @strawberry_django.filter_type(models.Tunnel, lookups=True) class TunnelFilter(TenancyFilterMixin, PrimaryModelFilterMixin): name: FilterLookup[str] | None = strawberry_django.filter_field() - status: Annotated['TunnelStatusEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + status: FilterLookup[Annotated['TunnelStatusEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( strawberry_django.filter_field() ) group: Annotated['TunnelGroupFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( strawberry_django.filter_field() ) group_id: ID | None = strawberry_django.filter_field() - encapsulation: Annotated['TunnelEncapsulationEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + encapsulation: FilterLookup[Annotated['TunnelEncapsulationEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( strawberry_django.filter_field() ) ipsec_profile: Annotated['IPSecProfileFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( @@ -83,16 +85,24 @@ class TunnelFilter(TenancyFilterMixin, PrimaryModelFilterMixin): @strawberry_django.filter_type(models.IKEProposal, lookups=True) class IKEProposalFilter(PrimaryModelFilterMixin): name: FilterLookup[str] | None = strawberry_django.filter_field() - authentication_method: Annotated['AuthenticationMethodEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + authentication_method: ( + FilterLookup[Annotated['AuthenticationMethodEnum', strawberry.lazy('vpn.graphql.enums')]] | None + ) = ( strawberry_django.filter_field() ) - encryption_algorithm: Annotated['EncryptionAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + encryption_algorithm: ( + FilterLookup[Annotated['EncryptionAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')]] | None + ) = ( strawberry_django.filter_field() ) - authentication_algorithm: Annotated['AuthenticationAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + authentication_algorithm: ( + FilterLookup[Annotated['AuthenticationAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')]] | None + ) = ( + strawberry_django.filter_field() + ) + group: FilterLookup[Annotated['DHGroupEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - group: Annotated['DHGroupEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() sa_lifetime: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() ) @@ -104,8 +114,12 @@ class IKEProposalFilter(PrimaryModelFilterMixin): @strawberry_django.filter_type(models.IKEPolicy, lookups=True) class IKEPolicyFilter(PrimaryModelFilterMixin): name: FilterLookup[str] | None = strawberry_django.filter_field() - version: Annotated['IKEVersionEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() - mode: Annotated['IKEModeEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() + version: FilterLookup[Annotated['IKEVersionEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) + mode: FilterLookup[Annotated['IKEModeEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) proposals: Annotated['IKEProposalFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( strawberry_django.filter_field() ) @@ -115,10 +129,14 @@ class IKEPolicyFilter(PrimaryModelFilterMixin): @strawberry_django.filter_type(models.IPSecProposal, lookups=True) class IPSecProposalFilter(PrimaryModelFilterMixin): name: FilterLookup[str] | None = strawberry_django.filter_field() - encryption_algorithm: Annotated['EncryptionAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + encryption_algorithm: ( + FilterLookup[Annotated['EncryptionAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')]] | None + ) = ( strawberry_django.filter_field() ) - authentication_algorithm: Annotated['AuthenticationAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + authentication_algorithm: FilterLookup[ + Annotated['AuthenticationAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')] + ] | None = ( strawberry_django.filter_field() ) sa_lifetime_seconds: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( @@ -138,13 +156,17 @@ class IPSecPolicyFilter(PrimaryModelFilterMixin): proposals: Annotated['IPSecProposalFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( strawberry_django.filter_field() ) - pfs_group: Annotated['DHGroupEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() + pfs_group: FilterLookup[Annotated['DHGroupEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter_type(models.IPSecProfile, lookups=True) class IPSecProfileFilter(PrimaryModelFilterMixin): name: FilterLookup[str] | None = strawberry_django.filter_field() - mode: Annotated['IPSecModeEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() + mode: FilterLookup[Annotated['IPSecModeEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) ike_policy: Annotated['IKEPolicyFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( strawberry_django.filter_field() ) @@ -159,7 +181,9 @@ class IPSecProfileFilter(PrimaryModelFilterMixin): class L2VPNFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin): name: FilterLookup[str] | None = strawberry_django.filter_field() slug: FilterLookup[str] | None = strawberry_django.filter_field() - type: Annotated['L2VPNTypeEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() + type: FilterLookup[Annotated['L2VPNTypeEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) identifier: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() ) diff --git a/netbox/wireless/graphql/filters.py b/netbox/wireless/graphql/filters.py index 5bd22afab..bcf10a7b4 100644 --- a/netbox/wireless/graphql/filters.py +++ b/netbox/wireless/graphql/filters.py @@ -36,7 +36,7 @@ class WirelessLANFilter( PrimaryModelFilterMixin ): ssid: FilterLookup[str] | None = strawberry_django.filter_field() - status: Annotated['WirelessLANStatusEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + status: FilterLookup[Annotated['WirelessLANStatusEnum', strawberry.lazy('wireless.graphql.enums')]] | None = ( strawberry_django.filter_field() ) group: Annotated['WirelessLANGroupFilter', strawberry.lazy('wireless.graphql.filters')] | None = ( @@ -63,6 +63,6 @@ class WirelessLinkFilter( ) interface_b_id: ID | None = strawberry_django.filter_field() ssid: FilterLookup[str] | None = strawberry_django.filter_field() - status: Annotated['WirelessLANStatusEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + status: FilterLookup[Annotated['WirelessLANStatusEnum', strawberry.lazy('wireless.graphql.enums')]] | None = ( strawberry_django.filter_field() ) From c7d94bd5291aba68299d191243bde3e77c1c28cc Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Fri, 24 Oct 2025 17:47:34 -0400 Subject: [PATCH 084/125] Change usages of FilterLookup to BaseFilterLookup --- netbox/circuits/graphql/filters.py | 14 +-- netbox/dcim/graphql/filters.py | 120 ++++++++++++----------- netbox/extras/graphql/filters.py | 26 ++--- netbox/ipam/graphql/filters.py | 20 ++-- netbox/tenancy/graphql/filters.py | 4 +- netbox/virtualization/graphql/filters.py | 6 +- netbox/vpn/graphql/filters.py | 40 ++++---- netbox/wireless/graphql/filters.py | 6 +- 8 files changed, 124 insertions(+), 112 deletions(-) diff --git a/netbox/circuits/graphql/filters.py b/netbox/circuits/graphql/filters.py index 545e76cb8..25cb8092d 100644 --- a/netbox/circuits/graphql/filters.py +++ b/netbox/circuits/graphql/filters.py @@ -4,7 +4,7 @@ from typing import Annotated, TYPE_CHECKING import strawberry import strawberry_django from strawberry.scalars import ID -from strawberry_django import FilterLookup, DateFilterLookup +from strawberry_django import BaseFilterLookup, FilterLookup, DateFilterLookup from circuits import models from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin @@ -53,7 +53,7 @@ class CircuitTerminationFilter( strawberry_django.filter_field() ) term_side: ( - FilterLookup[Annotated['CircuitTerminationSideEnum', strawberry.lazy('circuits.graphql.enums')]] | None + BaseFilterLookup[Annotated['CircuitTerminationSideEnum', strawberry.lazy('circuits.graphql.enums')]] | None ) = ( strawberry_django.filter_field() ) @@ -110,7 +110,7 @@ class CircuitFilter( strawberry_django.filter_field() ) type_id: ID | None = strawberry_django.filter_field() - status: FilterLookup[Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')]] | None = ( + status: BaseFilterLookup[Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')]] | None = ( strawberry_django.filter_field() ) install_date: DateFilterLookup[date] | None = strawberry_django.filter_field() @@ -145,7 +145,7 @@ class CircuitGroupAssignmentFilter( strawberry_django.filter_field() ) group_id: ID | None = strawberry_django.filter_field() - priority: FilterLookup[Annotated['CircuitPriorityEnum', strawberry.lazy('circuits.graphql.enums')]] | None = ( + priority: BaseFilterLookup[Annotated['CircuitPriorityEnum', strawberry.lazy('circuits.graphql.enums')]] | None = ( strawberry_django.filter_field() ) @@ -200,7 +200,7 @@ class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilterMixin): strawberry_django.filter_field() ) type_id: ID | None = strawberry_django.filter_field() - status: FilterLookup[Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')]] | None = ( + status: BaseFilterLookup[Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')]] | None = ( strawberry_django.filter_field() ) group_assignments: Annotated['CircuitGroupAssignmentFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( @@ -217,7 +217,9 @@ class VirtualCircuitTerminationFilter( ) virtual_circuit_id: ID | None = strawberry_django.filter_field() role: ( - FilterLookup[Annotated['VirtualCircuitTerminationRoleEnum', strawberry.lazy('circuits.graphql.enums')]] | None + BaseFilterLookup[ + Annotated['VirtualCircuitTerminationRoleEnum', strawberry.lazy('circuits.graphql.enums')] + ] | None ) = ( strawberry_django.filter_field() ) diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py index 4ccb85580..de156b55b 100644 --- a/netbox/dcim/graphql/filters.py +++ b/netbox/dcim/graphql/filters.py @@ -4,7 +4,7 @@ from django.db.models import Q import strawberry import strawberry_django from strawberry.scalars import ID -from strawberry_django import ComparisonFilterLookup, FilterLookup +from strawberry_django import BaseFilterLookup, ComparisonFilterLookup, FilterLookup from core.graphql.filter_mixins import ChangeLogFilterMixin from dcim import models @@ -97,20 +97,20 @@ __all__ = ( @strawberry_django.filter_type(models.Cable, lookups=True) class CableFilter(PrimaryModelFilterMixin, TenancyFilterMixin): - type: FilterLookup[Annotated['CableTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + type: BaseFilterLookup[Annotated['CableTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - status: FilterLookup[Annotated['LinkStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + status: BaseFilterLookup[Annotated['LinkStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) label: FilterLookup[str] | None = strawberry_django.filter_field() - color: FilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( + color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( strawberry_django.filter_field() ) length: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() ) - length_unit: FilterLookup[Annotated['CableLengthUnitEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + length_unit: BaseFilterLookup[Annotated['CableLengthUnitEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) terminations: Annotated['CableTerminationFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( @@ -122,7 +122,7 @@ class CableFilter(PrimaryModelFilterMixin, TenancyFilterMixin): class CableTerminationFilter(ChangeLogFilterMixin): cable: Annotated['CableFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() cable_id: ID | None = strawberry_django.filter_field() - cable_end: FilterLookup[Annotated['CableEndEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + cable_end: BaseFilterLookup[Annotated['CableEndEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) termination_type: Annotated['CableTerminationFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( @@ -133,34 +133,34 @@ class CableTerminationFilter(ChangeLogFilterMixin): @strawberry_django.filter_type(models.ConsolePort, lookups=True) class ConsolePortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): - type: FilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + type: BaseFilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - speed: FilterLookup[Annotated['ConsolePortSpeedEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + speed: BaseFilterLookup[Annotated['ConsolePortSpeedEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) @strawberry_django.filter_type(models.ConsolePortTemplate, lookups=True) class ConsolePortTemplateFilter(ModularComponentTemplateFilterMixin): - type: FilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + type: BaseFilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) @strawberry_django.filter_type(models.ConsoleServerPort, lookups=True) class ConsoleServerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): - type: FilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + type: BaseFilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - speed: FilterLookup[Annotated['ConsolePortSpeedEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + speed: BaseFilterLookup[Annotated['ConsolePortSpeedEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) @strawberry_django.filter_type(models.ConsoleServerPortTemplate, lookups=True) class ConsoleServerPortTemplateFilter(ModularComponentTemplateFilterMixin): - type: FilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + type: BaseFilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) @@ -201,13 +201,13 @@ class DeviceFilter( position: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() ) - face: FilterLookup[Annotated['DeviceFaceEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + face: BaseFilterLookup[Annotated['DeviceFaceEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - status: FilterLookup[Annotated['DeviceStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + status: BaseFilterLookup[Annotated['DeviceStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - airflow: FilterLookup[Annotated['DeviceAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + airflow: BaseFilterLookup[Annotated['DeviceAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) primary_ip4: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( @@ -319,7 +319,7 @@ class InventoryItemTemplateFilter(ComponentTemplateFilterMixin): @strawberry_django.filter_type(models.DeviceRole, lookups=True) class DeviceRoleFilter(OrganizationalModelFilterMixin, RenderConfigFilterMixin): - color: FilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( + color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( strawberry_django.filter_field() ) vm_role: FilterLookup[bool] | None = strawberry_django.filter_field() @@ -346,10 +346,10 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig ) exclude_from_utilization: FilterLookup[bool] | None = strawberry_django.filter_field() is_full_depth: FilterLookup[bool] | None = strawberry_django.filter_field() - subdevice_role: FilterLookup[Annotated['SubdeviceRoleEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + subdevice_role: BaseFilterLookup[Annotated['SubdeviceRoleEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - airflow: FilterLookup[Annotated['DeviceAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + airflow: BaseFilterLookup[Annotated['DeviceAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) front_image: Annotated['ImageAttachmentFilter', strawberry.lazy('extras.graphql.filters')] | None = ( @@ -403,10 +403,10 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig @strawberry_django.filter_type(models.FrontPort, lookups=True) class FrontPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): - type: FilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + type: BaseFilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - color: FilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( + color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( strawberry_django.filter_field() ) rear_port: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( @@ -420,10 +420,10 @@ class FrontPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterM @strawberry_django.filter_type(models.FrontPortTemplate, lookups=True) class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin): - type: FilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + type: BaseFilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - color: FilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( + color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( strawberry_django.filter_field() ) rear_port: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( @@ -469,14 +469,14 @@ class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin ) lag: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() lag_id: ID | None = strawberry_django.filter_field() - type: FilterLookup[Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + type: BaseFilterLookup[Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) mgmt_only: FilterLookup[bool] | None = strawberry_django.filter_field() speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() ) - duplex: FilterLookup[Annotated['InterfaceDuplexEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + duplex: BaseFilterLookup[Annotated['InterfaceDuplexEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) wwn: FilterLookup[str] | None = strawberry_django.filter_field() @@ -484,10 +484,10 @@ class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin strawberry_django.filter_field() ) parent_id: ID | None = strawberry_django.filter_field() - rf_role: FilterLookup[Annotated['WirelessRoleEnum', strawberry.lazy('wireless.graphql.enums')]] | None = ( + rf_role: BaseFilterLookup[Annotated['WirelessRoleEnum', strawberry.lazy('wireless.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - rf_channel: FilterLookup[Annotated['WirelessChannelEnum', strawberry.lazy('wireless.graphql.enums')]] | None = ( + rf_channel: BaseFilterLookup[Annotated['WirelessChannelEnum', strawberry.lazy('wireless.graphql.enums')]] | None = ( strawberry_django.filter_field() ) rf_channel_frequency: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( @@ -499,10 +499,10 @@ class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin tx_power: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() ) - poe_mode: FilterLookup[Annotated['InterfacePoEModeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + poe_mode: BaseFilterLookup[Annotated['InterfacePoEModeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - poe_type: FilterLookup[Annotated['InterfacePoETypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + poe_type: BaseFilterLookup[Annotated['InterfacePoETypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) wireless_link: Annotated['WirelessLinkFilter', strawberry.lazy('wireless.graphql.filters')] | None = ( @@ -554,7 +554,7 @@ class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin @strawberry_django.filter_type(models.InterfaceTemplate, lookups=True) class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin): - type: FilterLookup[Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + type: BaseFilterLookup[Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) enabled: FilterLookup[bool] | None = strawberry_django.filter_field() @@ -563,13 +563,13 @@ class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin): strawberry_django.filter_field() ) bridge_id: ID | None = strawberry_django.filter_field() - poe_mode: FilterLookup[Annotated['InterfacePoEModeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + poe_mode: BaseFilterLookup[Annotated['InterfacePoEModeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - poe_type: FilterLookup[Annotated['InterfacePoETypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + poe_type: BaseFilterLookup[Annotated['InterfacePoETypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - rf_role: FilterLookup[Annotated['WirelessRoleEnum', strawberry.lazy('wireless.graphql.enums')]] | None = ( + rf_role: BaseFilterLookup[Annotated['WirelessRoleEnum', strawberry.lazy('wireless.graphql.enums')]] | None = ( strawberry_django.filter_field() ) @@ -584,7 +584,7 @@ class InventoryItemFilter(ComponentModelFilterMixin): strawberry_django.filter_field() ) component_id: ID | None = strawberry_django.filter_field() - status: FilterLookup[Annotated['InventoryItemStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + status: BaseFilterLookup[Annotated['InventoryItemStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) role: Annotated['InventoryItemRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( @@ -603,7 +603,7 @@ class InventoryItemFilter(ComponentModelFilterMixin): @strawberry_django.filter_type(models.InventoryItemRole, lookups=True) class InventoryItemRoleFilter(OrganizationalModelFilterMixin): - color: FilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( + color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( strawberry_django.filter_field() ) @@ -612,7 +612,7 @@ class InventoryItemRoleFilter(OrganizationalModelFilterMixin): class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, NestedGroupModelFilterMixin): site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() site_id: ID | None = strawberry_django.filter_field() - status: FilterLookup[Annotated['LocationStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + status: BaseFilterLookup[Annotated['LocationStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) facility: FilterLookup[str] | None = strawberry_django.filter_field() @@ -641,7 +641,7 @@ class ModuleFilter(PrimaryModelFilterMixin, ConfigContextFilterMixin): strawberry_django.filter_field() ) module_type_id: ID | None = strawberry_django.filter_field() - status: FilterLookup[Annotated['ModuleStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + status: BaseFilterLookup[Annotated['ModuleStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) serial: FilterLookup[str] | None = strawberry_django.filter_field() @@ -712,7 +712,7 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig instances: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() ) - airflow: FilterLookup[Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + airflow: BaseFilterLookup[Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) console_port_templates: ( @@ -769,16 +769,16 @@ class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryM rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() rack_id: ID | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field() - status: FilterLookup[Annotated['PowerFeedStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + status: BaseFilterLookup[Annotated['PowerFeedStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - type: FilterLookup[Annotated['PowerFeedTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + type: BaseFilterLookup[Annotated['PowerFeedTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - supply: FilterLookup[Annotated['PowerFeedSupplyEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + supply: BaseFilterLookup[Annotated['PowerFeedSupplyEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - phase: FilterLookup[Annotated['PowerFeedPhaseEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + phase: BaseFilterLookup[Annotated['PowerFeedPhaseEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) voltage: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( @@ -797,31 +797,31 @@ class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryM @strawberry_django.filter_type(models.PowerOutlet, lookups=True) class PowerOutletFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): - type: FilterLookup[Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + type: BaseFilterLookup[Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) power_port: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() ) power_port_id: ID | None = strawberry_django.filter_field() - feed_leg: FilterLookup[Annotated['PowerOutletFeedLegEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + feed_leg: BaseFilterLookup[Annotated['PowerOutletFeedLegEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - color: FilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( + color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( strawberry_django.filter_field() ) @strawberry_django.filter_type(models.PowerOutletTemplate, lookups=True) class PowerOutletTemplateFilter(ModularComponentModelFilterMixin): - type: FilterLookup[Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + type: BaseFilterLookup[Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) power_port: Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() ) power_port_id: ID | None = strawberry_django.filter_field() - feed_leg: FilterLookup[Annotated['PowerOutletFeedLegEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + feed_leg: BaseFilterLookup[Annotated['PowerOutletFeedLegEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) @@ -841,7 +841,7 @@ class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryMo @strawberry_django.filter_type(models.PowerPort, lookups=True) class PowerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): - type: FilterLookup[Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + type: BaseFilterLookup[Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) maximum_draw: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( @@ -854,7 +854,7 @@ class PowerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterM @strawberry_django.filter_type(models.PowerPortTemplate, lookups=True) class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin): - type: FilterLookup[Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + type: BaseFilterLookup[Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) maximum_draw: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( @@ -867,7 +867,7 @@ class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin): @strawberry_django.filter_type(models.RackType, lookups=True) class RackTypeFilter(RackBaseFilterMixin): - form_factor: FilterLookup[Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + form_factor: BaseFilterLookup[Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( @@ -882,7 +882,7 @@ class RackTypeFilter(RackBaseFilterMixin): @strawberry_django.filter_type(models.Rack, lookups=True) class RackFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, RackBaseFilterMixin): - form_factor: FilterLookup[Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + form_factor: BaseFilterLookup[Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) rack_type: Annotated['RackTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( @@ -899,14 +899,14 @@ class RackFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMi location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() ) - status: FilterLookup[Annotated['RackStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + status: BaseFilterLookup[Annotated['RackStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) role: Annotated['RackRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() role_id: ID | None = strawberry_django.filter_field() serial: FilterLookup[str] | None = strawberry_django.filter_field() asset_tag: FilterLookup[str] | None = strawberry_django.filter_field() - airflow: FilterLookup[Annotated['RackAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + airflow: BaseFilterLookup[Annotated['RackAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( @@ -928,17 +928,17 @@ class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilterMixin): @strawberry_django.filter_type(models.RackRole, lookups=True) class RackRoleFilter(OrganizationalModelFilterMixin): - color: FilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( + color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( strawberry_django.filter_field() ) @strawberry_django.filter_type(models.RearPort, lookups=True) class RearPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): - type: FilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + type: BaseFilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - color: FilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( + color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( strawberry_django.filter_field() ) positions: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( @@ -948,10 +948,10 @@ class RearPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMi @strawberry_django.filter_type(models.RearPortTemplate, lookups=True) class RearPortTemplateFilter(ModularComponentTemplateFilterMixin): - type: FilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + type: BaseFilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - color: FilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( + color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( strawberry_django.filter_field() ) positions: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( @@ -973,7 +973,7 @@ class RegionFilter(ContactFilterMixin, NestedGroupModelFilterMixin): class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin): name: FilterLookup[str] | None = strawberry_django.filter_field() slug: FilterLookup[str] | None = strawberry_django.filter_field() - status: FilterLookup[Annotated['SiteStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + status: BaseFilterLookup[Annotated['SiteStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) region: Annotated['RegionFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() @@ -1032,7 +1032,9 @@ class VirtualDeviceContextFilter(TenancyFilterMixin, PrimaryModelFilterMixin): device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() device_id: ID | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field() - status: FilterLookup[Annotated['VirtualDeviceContextStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + status: ( + BaseFilterLookup[Annotated['VirtualDeviceContextStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None + ) = ( strawberry_django.filter_field() ) identifier: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( diff --git a/netbox/extras/graphql/filters.py b/netbox/extras/graphql/filters.py index 75aaac955..724c53421 100644 --- a/netbox/extras/graphql/filters.py +++ b/netbox/extras/graphql/filters.py @@ -3,7 +3,7 @@ from typing import Annotated, TYPE_CHECKING import strawberry import strawberry_django from strawberry.scalars import ID -from strawberry_django import FilterLookup +from strawberry_django import BaseFilterLookup, FilterLookup from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin from extras import models @@ -121,7 +121,7 @@ class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Cha @strawberry_django.filter_type(models.CustomField, lookups=True) class CustomFieldFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): - type: FilterLookup[Annotated['CustomFieldTypeEnum', strawberry.lazy('extras.graphql.enums')]] | None = ( + type: BaseFilterLookup[Annotated['CustomFieldTypeEnum', strawberry.lazy('extras.graphql.enums')]] | None = ( strawberry_django.filter_field() ) object_types: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( @@ -140,7 +140,7 @@ class CustomFieldFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): strawberry_django.filter_field() ) filter_logic: ( - FilterLookup[Annotated['CustomFieldFilterLogicEnum', strawberry.lazy('extras.graphql.enums')]] | None + BaseFilterLookup[Annotated['CustomFieldFilterLogicEnum', strawberry.lazy('extras.graphql.enums')]] | None ) = ( strawberry_django.filter_field() ) @@ -164,11 +164,13 @@ class CustomFieldFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): strawberry_django.filter_field() ) choice_set_id: ID | None = strawberry_django.filter_field() - ui_visible: FilterLookup[Annotated['CustomFieldUIVisibleEnum', strawberry.lazy('extras.graphql.enums')]] | None = ( + ui_visible: ( + BaseFilterLookup[Annotated['CustomFieldUIVisibleEnum', strawberry.lazy('extras.graphql.enums')]] | None + ) = ( strawberry_django.filter_field() ) ui_editable: ( - FilterLookup[Annotated['CustomFieldUIEditableEnum', strawberry.lazy('extras.graphql.enums')]] | None + BaseFilterLookup[Annotated['CustomFieldUIEditableEnum', strawberry.lazy('extras.graphql.enums')]] | None ) = ( strawberry_django.filter_field() ) @@ -181,7 +183,7 @@ class CustomFieldChoiceSetFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin name: FilterLookup[str] | None = strawberry_django.filter_field() description: FilterLookup[str] | None = strawberry_django.filter_field() base_choices: ( - FilterLookup[Annotated['CustomFieldChoiceSetBaseEnum', strawberry.lazy('extras.graphql.enums')]] | None + BaseFilterLookup[Annotated['CustomFieldChoiceSetBaseEnum', strawberry.lazy('extras.graphql.enums')]] | None ) = ( strawberry_django.filter_field() ) @@ -202,7 +204,7 @@ class CustomLinkFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): ) group_name: FilterLookup[str] | None = strawberry_django.filter_field() button_class: ( - FilterLookup[Annotated['CustomLinkButtonClassEnum', strawberry.lazy('extras.graphql.enums')]] | None + BaseFilterLookup[Annotated['CustomLinkButtonClassEnum', strawberry.lazy('extras.graphql.enums')]] | None ) = ( strawberry_django.filter_field() ) @@ -248,7 +250,7 @@ class JournalEntryFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, Tag created_by: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = ( strawberry_django.filter_field() ) - kind: FilterLookup[Annotated['JournalEntryKindEnum', strawberry.lazy('extras.graphql.enums')]] | None = ( + kind: BaseFilterLookup[Annotated['JournalEntryKindEnum', strawberry.lazy('extras.graphql.enums')]] | None = ( strawberry_django.filter_field() ) comments: FilterLookup[str] | None = strawberry_django.filter_field() @@ -294,7 +296,7 @@ class TableConfigFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): @strawberry_django.filter_type(models.Tag, lookups=True) class TagFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin, TagBaseFilterMixin): - color: FilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( + color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( strawberry_django.filter_field() ) description: FilterLookup[str] | None = strawberry_django.filter_field() @@ -305,7 +307,9 @@ class WebhookFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilt name: FilterLookup[str] | None = strawberry_django.filter_field() description: FilterLookup[str] | None = strawberry_django.filter_field() payload_url: FilterLookup[str] | None = strawberry_django.filter_field() - http_method: FilterLookup[Annotated['WebhookHttpMethodEnum', strawberry.lazy('extras.graphql.enums')]] | None = ( + http_method: ( + BaseFilterLookup[Annotated['WebhookHttpMethodEnum', strawberry.lazy('extras.graphql.enums')]] | None + ) = ( strawberry_django.filter_field() ) http_content_type: FilterLookup[str] | None = strawberry_django.filter_field() @@ -330,7 +334,7 @@ class EventRuleFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFi conditions: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() ) - action_type: FilterLookup[Annotated['EventRuleActionEnum', strawberry.lazy('extras.graphql.enums')]] | None = ( + action_type: BaseFilterLookup[Annotated['EventRuleActionEnum', strawberry.lazy('extras.graphql.enums')]] | None = ( strawberry_django.filter_field() ) action_object_type: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/ipam/graphql/filters.py b/netbox/ipam/graphql/filters.py index ce22c16ed..67d7a723d 100644 --- a/netbox/ipam/graphql/filters.py +++ b/netbox/ipam/graphql/filters.py @@ -7,7 +7,7 @@ import strawberry_django from django.db.models import Q from netaddr.core import AddrFormatError from strawberry.scalars import ID -from strawberry_django import FilterLookup, DateFilterLookup +from strawberry_django import BaseFilterLookup, FilterLookup, DateFilterLookup from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin from dcim.graphql.filter_mixins import ScopedFilterMixin @@ -116,10 +116,10 @@ class FHRPGroupFilter(PrimaryModelFilterMixin): strawberry_django.filter_field() ) name: FilterLookup[str] | None = strawberry_django.filter_field() - protocol: FilterLookup[Annotated['FHRPGroupProtocolEnum', strawberry.lazy('ipam.graphql.enums')]] | None = ( + protocol: BaseFilterLookup[Annotated['FHRPGroupProtocolEnum', strawberry.lazy('ipam.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - auth_type: FilterLookup[Annotated['FHRPGroupAuthTypeEnum', strawberry.lazy('ipam.graphql.enums')]] | None = ( + auth_type: BaseFilterLookup[Annotated['FHRPGroupAuthTypeEnum', strawberry.lazy('ipam.graphql.enums')]] | None = ( strawberry_django.filter_field() ) auth_key: FilterLookup[str] | None = strawberry_django.filter_field() @@ -172,10 +172,10 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter address: FilterLookup[str] | None = strawberry_django.filter_field() vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() vrf_id: ID | None = strawberry_django.filter_field() - status: FilterLookup[Annotated['IPAddressStatusEnum', strawberry.lazy('ipam.graphql.enums')]] | None = ( + status: BaseFilterLookup[Annotated['IPAddressStatusEnum', strawberry.lazy('ipam.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - role: FilterLookup[Annotated['IPAddressRoleEnum', strawberry.lazy('ipam.graphql.enums')]] | None = ( + role: BaseFilterLookup[Annotated['IPAddressRoleEnum', strawberry.lazy('ipam.graphql.enums')]] | None = ( strawberry_django.filter_field() ) assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( @@ -212,7 +212,7 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter @strawberry_django.filter_field() def family( self, - value: FilterLookup[Annotated['IPAddressFamilyEnum', strawberry.lazy('ipam.graphql.enums')]], + value: Annotated['IPAddressFamilyEnum', strawberry.lazy('ipam.graphql.enums')], prefix, ) -> Q: return Q(**{f"{prefix}address__family": value.value}) @@ -227,7 +227,7 @@ class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMi ) vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() vrf_id: ID | None = strawberry_django.filter_field() - status: FilterLookup[Annotated['IPRangeStatusEnum', strawberry.lazy('ipam.graphql.enums')]] | None = ( + status: BaseFilterLookup[Annotated['IPRangeStatusEnum', strawberry.lazy('ipam.graphql.enums')]] | None = ( strawberry_django.filter_field() ) role: Annotated['RoleFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() @@ -279,7 +279,7 @@ class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, Pr vrf_id: ID | None = strawberry_django.filter_field() vlan: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() vlan_id: ID | None = strawberry_django.filter_field() - status: FilterLookup[Annotated['PrefixStatusEnum', strawberry.lazy('ipam.graphql.enums')]] | None = ( + status: BaseFilterLookup[Annotated['PrefixStatusEnum', strawberry.lazy('ipam.graphql.enums')]] | None = ( strawberry_django.filter_field() ) role: Annotated['RoleFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() @@ -367,7 +367,7 @@ class VLANFilter(TenancyFilterMixin, PrimaryModelFilterMixin): strawberry_django.filter_field() ) name: FilterLookup[str] | None = strawberry_django.filter_field() - status: FilterLookup[Annotated['VLANStatusEnum', strawberry.lazy('ipam.graphql.enums')]] | None = ( + status: BaseFilterLookup[Annotated['VLANStatusEnum', strawberry.lazy('ipam.graphql.enums')]] | None = ( strawberry_django.filter_field() ) role: Annotated['RoleFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() @@ -379,7 +379,7 @@ class VLANFilter(TenancyFilterMixin, PrimaryModelFilterMixin): qinq_cvlans: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( strawberry_django.filter_field() ) - qinq_role: FilterLookup[Annotated['VLANQinQRoleEnum', strawberry.lazy('ipam.graphql.enums')]] | None = ( + qinq_role: BaseFilterLookup[Annotated['VLANQinQRoleEnum', strawberry.lazy('ipam.graphql.enums')]] | None = ( strawberry_django.filter_field() ) l2vpn_terminations: Annotated['L2VPNFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( diff --git a/netbox/tenancy/graphql/filters.py b/netbox/tenancy/graphql/filters.py index 08682c442..dbdbfd750 100644 --- a/netbox/tenancy/graphql/filters.py +++ b/netbox/tenancy/graphql/filters.py @@ -3,7 +3,7 @@ from typing import Annotated, TYPE_CHECKING import strawberry import strawberry_django from strawberry.scalars import ID -from strawberry_django import FilterLookup +from strawberry_django import BaseFilterLookup, FilterLookup from core.graphql.filter_mixins import ChangeLogFilterMixin from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin @@ -191,6 +191,6 @@ class ContactAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLo strawberry_django.filter_field() ) role_id: ID | None = strawberry_django.filter_field() - priority: FilterLookup[Annotated['ContactPriorityEnum', strawberry.lazy('tenancy.graphql.enums')]] | None = ( + priority: BaseFilterLookup[Annotated['ContactPriorityEnum', strawberry.lazy('tenancy.graphql.enums')]] | None = ( strawberry_django.filter_field() ) diff --git a/netbox/virtualization/graphql/filters.py b/netbox/virtualization/graphql/filters.py index 7ee8d9053..5b29e362a 100644 --- a/netbox/virtualization/graphql/filters.py +++ b/netbox/virtualization/graphql/filters.py @@ -3,7 +3,7 @@ from typing import Annotated, TYPE_CHECKING import strawberry import strawberry_django from strawberry.scalars import ID -from strawberry_django import FilterLookup +from strawberry_django import BaseFilterLookup, FilterLookup from dcim.graphql.filter_mixins import InterfaceBaseFilterMixin, RenderConfigFilterMixin, ScopedFilterMixin from extras.graphql.filter_mixins import ConfigContextFilterMixin @@ -50,7 +50,7 @@ class ClusterFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, P strawberry_django.filter_field() ) group_id: ID | None = strawberry_django.filter_field() - status: FilterLookup[Annotated['ClusterStatusEnum', strawberry.lazy('virtualization.graphql.enums')]] | None = ( + status: BaseFilterLookup[Annotated['ClusterStatusEnum', strawberry.lazy('virtualization.graphql.enums')]] | None = ( strawberry_django.filter_field() ) vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( @@ -93,7 +93,7 @@ class VirtualMachineFilter( ) platform_id: ID | None = strawberry_django.filter_field() status: ( - FilterLookup[Annotated['VirtualMachineStatusEnum', strawberry.lazy('virtualization.graphql.enums')]] | None + BaseFilterLookup[Annotated['VirtualMachineStatusEnum', strawberry.lazy('virtualization.graphql.enums')]] | None ) = ( strawberry_django.filter_field() ) diff --git a/netbox/vpn/graphql/filters.py b/netbox/vpn/graphql/filters.py index d7bd423ef..7955550c9 100644 --- a/netbox/vpn/graphql/filters.py +++ b/netbox/vpn/graphql/filters.py @@ -3,7 +3,7 @@ from typing import Annotated, TYPE_CHECKING import strawberry import strawberry_django from strawberry.scalars import ID -from strawberry_django import FilterLookup +from strawberry_django import BaseFilterLookup, FilterLookup from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin @@ -42,11 +42,11 @@ class TunnelTerminationFilter( ): tunnel: Annotated['TunnelFilter', strawberry.lazy('vpn.graphql.filters')] | None = strawberry_django.filter_field() tunnel_id: ID | None = strawberry_django.filter_field() - role: FilterLookup[Annotated['TunnelTerminationRoleEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( + role: BaseFilterLookup[Annotated['TunnelTerminationRoleEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( strawberry_django.filter_field() ) termination_type: ( - FilterLookup[Annotated['TunnelTerminationTypeEnum', strawberry.lazy('vpn.graphql.enums')]] | None + BaseFilterLookup[Annotated['TunnelTerminationTypeEnum', strawberry.lazy('vpn.graphql.enums')]] | None ) = ( strawberry_django.filter_field() ) @@ -61,14 +61,16 @@ class TunnelTerminationFilter( @strawberry_django.filter_type(models.Tunnel, lookups=True) class TunnelFilter(TenancyFilterMixin, PrimaryModelFilterMixin): name: FilterLookup[str] | None = strawberry_django.filter_field() - status: FilterLookup[Annotated['TunnelStatusEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( + status: BaseFilterLookup[Annotated['TunnelStatusEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( strawberry_django.filter_field() ) group: Annotated['TunnelGroupFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( strawberry_django.filter_field() ) group_id: ID | None = strawberry_django.filter_field() - encapsulation: FilterLookup[Annotated['TunnelEncapsulationEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( + encapsulation: ( + BaseFilterLookup[Annotated['TunnelEncapsulationEnum', strawberry.lazy('vpn.graphql.enums')]] | None + ) = ( strawberry_django.filter_field() ) ipsec_profile: Annotated['IPSecProfileFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( @@ -86,21 +88,21 @@ class TunnelFilter(TenancyFilterMixin, PrimaryModelFilterMixin): class IKEProposalFilter(PrimaryModelFilterMixin): name: FilterLookup[str] | None = strawberry_django.filter_field() authentication_method: ( - FilterLookup[Annotated['AuthenticationMethodEnum', strawberry.lazy('vpn.graphql.enums')]] | None + BaseFilterLookup[Annotated['AuthenticationMethodEnum', strawberry.lazy('vpn.graphql.enums')]] | None ) = ( strawberry_django.filter_field() ) encryption_algorithm: ( - FilterLookup[Annotated['EncryptionAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')]] | None + BaseFilterLookup[Annotated['EncryptionAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')]] | None ) = ( strawberry_django.filter_field() ) authentication_algorithm: ( - FilterLookup[Annotated['AuthenticationAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')]] | None + BaseFilterLookup[Annotated['AuthenticationAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')]] | None ) = ( strawberry_django.filter_field() ) - group: FilterLookup[Annotated['DHGroupEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( + group: BaseFilterLookup[Annotated['DHGroupEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( strawberry_django.filter_field() ) sa_lifetime: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( @@ -114,10 +116,10 @@ class IKEProposalFilter(PrimaryModelFilterMixin): @strawberry_django.filter_type(models.IKEPolicy, lookups=True) class IKEPolicyFilter(PrimaryModelFilterMixin): name: FilterLookup[str] | None = strawberry_django.filter_field() - version: FilterLookup[Annotated['IKEVersionEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( + version: BaseFilterLookup[Annotated['IKEVersionEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - mode: FilterLookup[Annotated['IKEModeEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( + mode: BaseFilterLookup[Annotated['IKEModeEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( strawberry_django.filter_field() ) proposals: Annotated['IKEProposalFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( @@ -130,13 +132,15 @@ class IKEPolicyFilter(PrimaryModelFilterMixin): class IPSecProposalFilter(PrimaryModelFilterMixin): name: FilterLookup[str] | None = strawberry_django.filter_field() encryption_algorithm: ( - FilterLookup[Annotated['EncryptionAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')]] | None + BaseFilterLookup[Annotated['EncryptionAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')]] | None ) = ( strawberry_django.filter_field() ) - authentication_algorithm: FilterLookup[ - Annotated['AuthenticationAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')] - ] | None = ( + authentication_algorithm: ( + BaseFilterLookup[ + BaseFilterLookup[Annotated['AuthenticationAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')]] + ] | None + ) = ( strawberry_django.filter_field() ) sa_lifetime_seconds: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( @@ -156,7 +160,7 @@ class IPSecPolicyFilter(PrimaryModelFilterMixin): proposals: Annotated['IPSecProposalFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( strawberry_django.filter_field() ) - pfs_group: FilterLookup[Annotated['DHGroupEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( + pfs_group: BaseFilterLookup[Annotated['DHGroupEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( strawberry_django.filter_field() ) @@ -164,7 +168,7 @@ class IPSecPolicyFilter(PrimaryModelFilterMixin): @strawberry_django.filter_type(models.IPSecProfile, lookups=True) class IPSecProfileFilter(PrimaryModelFilterMixin): name: FilterLookup[str] | None = strawberry_django.filter_field() - mode: FilterLookup[Annotated['IPSecModeEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( + mode: BaseFilterLookup[Annotated['IPSecModeEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( strawberry_django.filter_field() ) ike_policy: Annotated['IKEPolicyFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( @@ -181,7 +185,7 @@ class IPSecProfileFilter(PrimaryModelFilterMixin): class L2VPNFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin): name: FilterLookup[str] | None = strawberry_django.filter_field() slug: FilterLookup[str] | None = strawberry_django.filter_field() - type: FilterLookup[Annotated['L2VPNTypeEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( + type: BaseFilterLookup[Annotated['L2VPNTypeEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( strawberry_django.filter_field() ) identifier: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( diff --git a/netbox/wireless/graphql/filters.py b/netbox/wireless/graphql/filters.py index bcf10a7b4..342e078ed 100644 --- a/netbox/wireless/graphql/filters.py +++ b/netbox/wireless/graphql/filters.py @@ -3,7 +3,7 @@ from typing import Annotated, TYPE_CHECKING import strawberry import strawberry_django from strawberry.scalars import ID -from strawberry_django import FilterLookup +from strawberry_django import BaseFilterLookup, FilterLookup from dcim.graphql.filter_mixins import ScopedFilterMixin from netbox.graphql.filter_mixins import DistanceFilterMixin, PrimaryModelFilterMixin, NestedGroupModelFilterMixin @@ -36,7 +36,7 @@ class WirelessLANFilter( PrimaryModelFilterMixin ): ssid: FilterLookup[str] | None = strawberry_django.filter_field() - status: FilterLookup[Annotated['WirelessLANStatusEnum', strawberry.lazy('wireless.graphql.enums')]] | None = ( + status: BaseFilterLookup[Annotated['WirelessLANStatusEnum', strawberry.lazy('wireless.graphql.enums')]] | None = ( strawberry_django.filter_field() ) group: Annotated['WirelessLANGroupFilter', strawberry.lazy('wireless.graphql.filters')] | None = ( @@ -63,6 +63,6 @@ class WirelessLinkFilter( ) interface_b_id: ID | None = strawberry_django.filter_field() ssid: FilterLookup[str] | None = strawberry_django.filter_field() - status: FilterLookup[Annotated['WirelessLANStatusEnum', strawberry.lazy('wireless.graphql.enums')]] | None = ( + status: BaseFilterLookup[Annotated['WirelessLANStatusEnum', strawberry.lazy('wireless.graphql.enums')]] | None = ( strawberry_django.filter_field() ) From d192c1e352deb9d28d053885bf0e4b2d2836b2aa Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Fri, 24 Oct 2025 18:34:07 -0400 Subject: [PATCH 085/125] Merge feature --- netbox/dcim/graphql/types_v1.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/dcim/graphql/types_v1.py b/netbox/dcim/graphql/types_v1.py index c7f069ecb..53f89087a 100644 --- a/netbox/dcim/graphql/types_v1.py +++ b/netbox/dcim/graphql/types_v1.py @@ -677,6 +677,7 @@ class PowerOutletTypeV1(ModularComponentTypeV1, CabledObjectMixinV1, PathEndpoin ) class PowerOutletTemplateTypeV1(ModularComponentTemplateTypeV1): power_port: Annotated["PowerPortTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None + color: str @strawberry_django.type( From 3e1ccc80e988b399a96aa9af5c48f8f15cf5cc85 Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Fri, 24 Oct 2025 20:55:47 -0400 Subject: [PATCH 086/125] Set GRAPHQL_DEFAULT_VERSION = 2 in testing environment --- netbox/netbox/configuration_testing.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/netbox/configuration_testing.py b/netbox/netbox/configuration_testing.py index 3e552e944..69ced660d 100644 --- a/netbox/netbox/configuration_testing.py +++ b/netbox/netbox/configuration_testing.py @@ -51,3 +51,5 @@ LOGGING = { 'version': 1, 'disable_existing_loggers': True } + +GRAPHQL_DEFAULT_VERSION = 2 From ebeceaaa217dcd27031c91c51a902dbe36826e27 Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Fri, 24 Oct 2025 21:18:51 -0400 Subject: [PATCH 087/125] Integrate Owner and JournalEntries fields --- netbox/netbox/configuration_testing.py | 3 ++- netbox/netbox/graphql/types_v1.py | 35 +++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/netbox/netbox/configuration_testing.py b/netbox/netbox/configuration_testing.py index 69ced660d..6256af00a 100644 --- a/netbox/netbox/configuration_testing.py +++ b/netbox/netbox/configuration_testing.py @@ -52,4 +52,5 @@ LOGGING = { 'disable_existing_loggers': True } -GRAPHQL_DEFAULT_VERSION = 2 +# TODO: Switch to 2 +GRAPHQL_DEFAULT_VERSION = 1 diff --git a/netbox/netbox/graphql/types_v1.py b/netbox/netbox/graphql/types_v1.py index c6b3ad2ec..66a3ac905 100644 --- a/netbox/netbox/graphql/types_v1.py +++ b/netbox/netbox/graphql/types_v1.py @@ -6,13 +6,16 @@ from django.contrib.contenttypes.models import ContentType from core.graphql.mixins import ChangelogMixin from core.models import ObjectType as ObjectType_ from extras.graphql.mixins import CustomFieldsMixin, JournalEntriesMixin, TagsMixin +from users.graphql.mixins import OwnerMixin __all__ = ( 'BaseObjectTypeV1', 'ContentTypeTypeV1', + 'NestedGroupObjectTypeV1', + 'NetBoxObjectTypeV1', 'ObjectTypeV1', 'OrganizationalObjectTypeV1', - 'NetBoxObjectTypeV1', + 'PrimaryObjectTypeV1', ) @@ -53,10 +56,26 @@ class ObjectTypeV1( pass +class PrimaryObjectTypeV1( + ChangelogMixin, + CustomFieldsMixin, + JournalEntriesMixin, + TagsMixin, + OwnerMixin, + BaseObjectTypeV1 +): + """ + Base GraphQL type for models which inherit from PrimaryModel. + """ + pass + + class OrganizationalObjectTypeV1( ChangelogMixin, CustomFieldsMixin, + JournalEntriesMixin, TagsMixin, + OwnerMixin, BaseObjectTypeV1 ): """ @@ -65,6 +84,20 @@ class OrganizationalObjectTypeV1( pass +class NestedGroupObjectTypeV1( + ChangelogMixin, + CustomFieldsMixin, + JournalEntriesMixin, + TagsMixin, + OwnerMixin, + BaseObjectTypeV1 +): + """ + Base GraphQL type for models which inherit from NestedGroupModel. + """ + pass + + class NetBoxObjectTypeV1( ChangelogMixin, CustomFieldsMixin, From db3a4bc731e2dd1721c388be390ef8670c436fd8 Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Fri, 24 Oct 2025 23:11:35 -0400 Subject: [PATCH 088/125] Incorporate Owner fields/types into V1 classes --- netbox/circuits/graphql/types_v1.py | 14 ++++--- netbox/core/graphql/types_v1.py | 5 +-- netbox/dcim/graphql/types_v1.py | 44 ++++++++++------------ netbox/extras/graphql/types_v1.py | 21 ++++++----- netbox/ipam/graphql/types_v1.py | 31 +++++++-------- netbox/netbox/graphql/types_v1.py | 46 +++++++++++------------ netbox/tenancy/graphql/types_v1.py | 8 ++-- netbox/users/graphql/filters_v1.py | 23 ++++++++++++ netbox/users/graphql/mixins_v1.py | 15 ++++++++ netbox/users/graphql/schema_v1.py | 6 +++ netbox/users/graphql/types_v1.py | 24 +++++++++++- netbox/virtualization/graphql/types_v1.py | 9 +++-- netbox/vpn/graphql/types_v1.py | 11 +++--- netbox/wireless/graphql/types_v1.py | 8 ++-- 14 files changed, 166 insertions(+), 99 deletions(-) create mode 100644 netbox/users/graphql/mixins_v1.py diff --git a/netbox/circuits/graphql/types_v1.py b/netbox/circuits/graphql/types_v1.py index 3e7669df6..c9f9234dc 100644 --- a/netbox/circuits/graphql/types_v1.py +++ b/netbox/circuits/graphql/types_v1.py @@ -6,7 +6,9 @@ import strawberry_django from circuits import models from dcim.graphql.mixins_v1 import CabledObjectMixinV1 from extras.graphql.mixins_v1 import ContactsMixinV1, CustomFieldsMixinV1, TagsMixinV1 -from netbox.graphql.types_v1 import BaseObjectTypeV1, NetBoxObjectTypeV1, ObjectTypeV1, OrganizationalObjectTypeV1 +from netbox.graphql.types_v1 import ( + BaseObjectTypeV1, ObjectTypeV1, OrganizationalObjectTypeV1, PrimaryObjectTypeV1 +) from tenancy.graphql.types_v1 import TenantTypeV1 from .filters_v1 import * @@ -35,7 +37,7 @@ __all__ = ( filters=ProviderFilterV1, pagination=True ) -class ProviderTypeV1(NetBoxObjectTypeV1, ContactsMixinV1): +class ProviderTypeV1(ContactsMixinV1, PrimaryObjectTypeV1): networks: List[Annotated["ProviderNetworkTypeV1", strawberry.lazy('circuits.graphql.types_v1')]] circuits: List[Annotated["CircuitTypeV1", strawberry.lazy('circuits.graphql.types_v1')]] @@ -49,7 +51,7 @@ class ProviderTypeV1(NetBoxObjectTypeV1, ContactsMixinV1): filters=ProviderAccountFilterV1, pagination=True ) -class ProviderAccountTypeV1(ContactsMixinV1, NetBoxObjectTypeV1): +class ProviderAccountTypeV1(ContactsMixinV1, PrimaryObjectTypeV1): provider: Annotated["ProviderTypeV1", strawberry.lazy('circuits.graphql.types_v1')] circuits: List[Annotated["CircuitTypeV1", strawberry.lazy('circuits.graphql.types_v1')]] @@ -61,7 +63,7 @@ class ProviderAccountTypeV1(ContactsMixinV1, NetBoxObjectTypeV1): filters=ProviderNetworkFilterV1, pagination=True ) -class ProviderNetworkTypeV1(NetBoxObjectTypeV1): +class ProviderNetworkTypeV1(PrimaryObjectTypeV1): provider: Annotated["ProviderTypeV1", strawberry.lazy('circuits.graphql.types_v1')] circuit_terminations: List[Annotated["CircuitTerminationTypeV1", strawberry.lazy('circuits.graphql.types_v1')]] @@ -105,7 +107,7 @@ class CircuitTypeTypeV1(OrganizationalObjectTypeV1): filters=CircuitFilterV1, pagination=True ) -class CircuitTypeV1(NetBoxObjectTypeV1, ContactsMixinV1): +class CircuitTypeV1(PrimaryObjectTypeV1, ContactsMixinV1): provider: ProviderTypeV1 provider_account: ProviderAccountTypeV1 | None termination_a: CircuitTerminationTypeV1 | None @@ -178,7 +180,7 @@ class VirtualCircuitTerminationTypeV1(CustomFieldsMixinV1, TagsMixinV1, ObjectTy filters=VirtualCircuitFilterV1, pagination=True ) -class VirtualCircuitTypeV1(NetBoxObjectTypeV1): +class VirtualCircuitTypeV1(PrimaryObjectTypeV1): provider_network: ProviderNetworkTypeV1 = strawberry_django.field(select_related=["provider_network"]) provider_account: ProviderAccountTypeV1 | None type: Annotated["VirtualCircuitTypeTypeV1", strawberry.lazy('circuits.graphql.types_v1')] = strawberry_django.field( diff --git a/netbox/core/graphql/types_v1.py b/netbox/core/graphql/types_v1.py index 67effeae4..7dc8c0e3b 100644 --- a/netbox/core/graphql/types_v1.py +++ b/netbox/core/graphql/types_v1.py @@ -5,7 +5,7 @@ import strawberry_django from django.contrib.contenttypes.models import ContentType as DjangoContentType from core import models -from netbox.graphql.types_v1 import BaseObjectTypeV1, NetBoxObjectTypeV1 +from netbox.graphql.types_v1 import BaseObjectTypeV1, PrimaryObjectTypeV1 from .filters_v1 import * __all__ = ( @@ -32,8 +32,7 @@ class DataFileTypeV1(BaseObjectTypeV1): filters=DataSourceFilterV1, pagination=True ) -class DataSourceTypeV1(NetBoxObjectTypeV1): - +class DataSourceTypeV1(PrimaryObjectTypeV1): datafiles: List[Annotated["DataFileTypeV1", strawberry.lazy('core.graphql.types_v1')]] diff --git a/netbox/dcim/graphql/types_v1.py b/netbox/dcim/graphql/types_v1.py index 53f89087a..ab0ba5f07 100644 --- a/netbox/dcim/graphql/types_v1.py +++ b/netbox/dcim/graphql/types_v1.py @@ -8,13 +8,14 @@ from dcim import models from extras.graphql.mixins_v1 import ( ConfigContextMixinV1, ContactsMixinV1, - CustomFieldsMixinV1, ImageAttachmentsMixinV1, - TagsMixinV1, ) from ipam.graphql.mixins_v1 import IPAddressesMixinV1, VLANGroupsMixinV1 from netbox.graphql.scalars import BigInt -from netbox.graphql.types_v1 import BaseObjectTypeV1, NetBoxObjectTypeV1, OrganizationalObjectTypeV1 +from netbox.graphql.types_v1 import ( + BaseObjectTypeV1, NetBoxObjectTypeV1, OrganizationalObjectTypeV1, PrimaryObjectTypeV1 +) +from users.graphql.mixins_v1 import OwnerMixinV1 from .filters_v1 import * from .mixins_v1 import CabledObjectMixinV1, PathEndpointMixinV1 @@ -91,12 +92,7 @@ __all__ = ( @strawberry.type -class ComponentTypeV1( - ChangelogMixinV1, - CustomFieldsMixinV1, - TagsMixinV1, - BaseObjectTypeV1 -): +class ComponentTypeV1(OwnerMixinV1, NetBoxObjectTypeV1): """ Base type for device/VM components """ @@ -159,7 +155,7 @@ class CableTerminationTypeV1(NetBoxObjectTypeV1): filters=CableFilterV1, pagination=True ) -class CableTypeV1(NetBoxObjectTypeV1): +class CableTypeV1(PrimaryObjectTypeV1): color: str tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None @@ -236,7 +232,7 @@ class ConsoleServerPortTemplateTypeV1(ModularComponentTemplateTypeV1): filters=DeviceFilterV1, pagination=True ) -class DeviceTypeV1(ConfigContextMixinV1, ImageAttachmentsMixinV1, ContactsMixinV1, NetBoxObjectTypeV1): +class DeviceTypeV1(ConfigContextMixinV1, ImageAttachmentsMixinV1, ContactsMixinV1, PrimaryObjectTypeV1): console_port_count: BigInt console_server_port_count: BigInt power_port_count: BigInt @@ -355,7 +351,7 @@ class DeviceRoleTypeV1(OrganizationalObjectTypeV1): filters=DeviceTypeFilterV1, pagination=True ) -class DeviceTypeTypeV1(NetBoxObjectTypeV1): +class DeviceTypeTypeV1(PrimaryObjectTypeV1): console_port_template_count: BigInt console_server_port_template_count: BigInt power_port_template_count: BigInt @@ -414,7 +410,7 @@ class FrontPortTemplateTypeV1(ModularComponentTemplateTypeV1): filters=MACAddressFilterV1, pagination=True ) -class MACAddressTypeV1(NetBoxObjectTypeV1): +class MACAddressTypeV1(PrimaryObjectTypeV1): mac_address: str @strawberry_django.field @@ -557,7 +553,7 @@ class ManufacturerTypeV1(OrganizationalObjectTypeV1, ContactsMixinV1): filters=ModuleFilterV1, pagination=True ) -class ModuleTypeV1(NetBoxObjectTypeV1): +class ModuleTypeV1(PrimaryObjectTypeV1): device: Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] module_bay: Annotated["ModuleBayTypeV1", strawberry.lazy('dcim.graphql.types_v1')] module_type: Annotated["ModuleTypeTypeV1", strawberry.lazy('dcim.graphql.types_v1')] @@ -604,7 +600,7 @@ class ModuleBayTemplateTypeV1(ModularComponentTemplateTypeV1): filters=ModuleTypeProfileFilterV1, pagination=True ) -class ModuleTypeProfileTypeV1(NetBoxObjectTypeV1): +class ModuleTypeProfileTypeV1(PrimaryObjectTypeV1): module_types: List[Annotated["ModuleTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] @@ -614,7 +610,7 @@ class ModuleTypeProfileTypeV1(NetBoxObjectTypeV1): filters=ModuleTypeFilterV1, pagination=True ) -class ModuleTypeTypeV1(NetBoxObjectTypeV1): +class ModuleTypeTypeV1(PrimaryObjectTypeV1): profile: Annotated["ModuleTypeProfileTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None manufacturer: Annotated["ManufacturerTypeV1", strawberry.lazy('dcim.graphql.types_v1')] @@ -652,7 +648,7 @@ class PlatformTypeV1(OrganizationalObjectTypeV1): filters=PowerFeedFilterV1, pagination=True ) -class PowerFeedTypeV1(NetBoxObjectTypeV1, CabledObjectMixinV1, PathEndpointMixinV1): +class PowerFeedTypeV1(CabledObjectMixinV1, PathEndpointMixinV1, PrimaryObjectTypeV1): power_panel: Annotated["PowerPanelTypeV1", strawberry.lazy('dcim.graphql.types_v1')] rack: Annotated["RackTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None @@ -686,7 +682,7 @@ class PowerOutletTemplateTypeV1(ModularComponentTemplateTypeV1): filters=PowerPanelFilterV1, pagination=True ) -class PowerPanelTypeV1(NetBoxObjectTypeV1, ContactsMixinV1): +class PowerPanelTypeV1(ContactsMixinV1, PrimaryObjectTypeV1): site: Annotated["SiteTypeV1", strawberry.lazy('dcim.graphql.types_v1')] location: Annotated["LocationTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None @@ -720,7 +716,7 @@ class PowerPortTemplateTypeV1(ModularComponentTemplateTypeV1): filters=RackTypeFilterV1, pagination=True ) -class RackTypeTypeV1(NetBoxObjectTypeV1): +class RackTypeTypeV1(PrimaryObjectTypeV1): manufacturer: Annotated["ManufacturerTypeV1", strawberry.lazy('dcim.graphql.types_v1')] @@ -730,7 +726,7 @@ class RackTypeTypeV1(NetBoxObjectTypeV1): filters=RackFilterV1, pagination=True ) -class RackTypeV1(VLANGroupsMixinV1, ImageAttachmentsMixinV1, ContactsMixinV1, NetBoxObjectTypeV1): +class RackTypeV1(VLANGroupsMixinV1, ImageAttachmentsMixinV1, ContactsMixinV1, PrimaryObjectTypeV1): site: Annotated["SiteTypeV1", strawberry.lazy('dcim.graphql.types_v1')] location: Annotated["LocationTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None @@ -749,7 +745,7 @@ class RackTypeV1(VLANGroupsMixinV1, ImageAttachmentsMixinV1, ContactsMixinV1, Ne filters=RackReservationFilterV1, pagination=True ) -class RackReservationTypeV1(NetBoxObjectTypeV1): +class RackReservationTypeV1(PrimaryObjectTypeV1): units: List[int] rack: Annotated["RackTypeV1", strawberry.lazy('dcim.graphql.types_v1')] tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None @@ -824,7 +820,7 @@ class RegionTypeV1(VLANGroupsMixinV1, ContactsMixinV1, OrganizationalObjectTypeV filters=SiteFilterV1, pagination=True ) -class SiteTypeV1(VLANGroupsMixinV1, ImageAttachmentsMixinV1, ContactsMixinV1, NetBoxObjectTypeV1): +class SiteTypeV1(VLANGroupsMixinV1, ImageAttachmentsMixinV1, ContactsMixinV1, PrimaryObjectTypeV1): time_zone: str | None region: Annotated["RegionTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None group: Annotated["SiteGroupTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None @@ -885,7 +881,7 @@ class SiteGroupTypeV1(VLANGroupsMixinV1, ContactsMixinV1, OrganizationalObjectTy filters=VirtualChassisFilterV1, pagination=True ) -class VirtualChassisTypeV1(NetBoxObjectTypeV1): +class VirtualChassisTypeV1(PrimaryObjectTypeV1): member_count: BigInt master: Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None @@ -898,7 +894,7 @@ class VirtualChassisTypeV1(NetBoxObjectTypeV1): filters=VirtualDeviceContextFilterV1, pagination=True ) -class VirtualDeviceContextTypeV1(NetBoxObjectTypeV1): +class VirtualDeviceContextTypeV1(PrimaryObjectTypeV1): device: Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None primary_ip4: Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None primary_ip6: Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None diff --git a/netbox/extras/graphql/types_v1.py b/netbox/extras/graphql/types_v1.py index d51b57e88..1693fe1da 100644 --- a/netbox/extras/graphql/types_v1.py +++ b/netbox/extras/graphql/types_v1.py @@ -7,8 +7,9 @@ from core.graphql.mixins_v1 import SyncedDataMixinV1 from extras import models from extras.graphql.mixins_v1 import CustomFieldsMixinV1, TagsMixinV1 from netbox.graphql.types_v1 import ( - BaseObjectTypeV1, ContentTypeTypeV1, NetBoxObjectTypeV1, ObjectTypeV1, OrganizationalObjectTypeV1 + BaseObjectTypeV1, ContentTypeTypeV1, ObjectTypeV1, OrganizationalObjectTypeV1, PrimaryObjectTypeV1 ) +from users.graphql.mixins_v1 import OwnerMixinV1 from .filters_v1 import * if TYPE_CHECKING: @@ -55,7 +56,7 @@ __all__ = ( filters=ConfigContextProfileFilterV1, pagination=True ) -class ConfigContextProfileTypeV1(SyncedDataMixinV1, NetBoxObjectTypeV1): +class ConfigContextProfileTypeV1(SyncedDataMixinV1, PrimaryObjectTypeV1): pass @@ -65,7 +66,7 @@ class ConfigContextProfileTypeV1(SyncedDataMixinV1, NetBoxObjectTypeV1): filters=ConfigContextFilterV1, pagination=True ) -class ConfigContextTypeV1(SyncedDataMixinV1, ObjectTypeV1): +class ConfigContextTypeV1(SyncedDataMixinV1, OwnerMixinV1, ObjectTypeV1): profile: ConfigContextProfileTypeV1 | None roles: List[Annotated["DeviceRoleTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] device_types: List[Annotated["DeviceTypeTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] @@ -88,7 +89,7 @@ class ConfigContextTypeV1(SyncedDataMixinV1, ObjectTypeV1): filters=ConfigTemplateFilterV1, pagination=True ) -class ConfigTemplateTypeV1(SyncedDataMixinV1, TagsMixinV1, ObjectTypeV1): +class ConfigTemplateTypeV1(SyncedDataMixinV1, OwnerMixinV1, TagsMixinV1, ObjectTypeV1): virtualmachines: List[Annotated["VirtualMachineTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] devices: List[Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] platforms: List[Annotated["PlatformTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] @@ -101,7 +102,7 @@ class ConfigTemplateTypeV1(SyncedDataMixinV1, TagsMixinV1, ObjectTypeV1): filters=CustomFieldFilterV1, pagination=True ) -class CustomFieldTypeV1(ObjectTypeV1): +class CustomFieldTypeV1(OwnerMixinV1, ObjectTypeV1): related_object_type: Annotated["ContentTypeTypeV1", strawberry.lazy('netbox.graphql.types_v1')] | None choice_set: Annotated["CustomFieldChoiceSetTypeV1", strawberry.lazy('extras.graphql.types_v1')] | None @@ -112,7 +113,7 @@ class CustomFieldTypeV1(ObjectTypeV1): filters=CustomFieldChoiceSetFilterV1, pagination=True ) -class CustomFieldChoiceSetTypeV1(ObjectTypeV1): +class CustomFieldChoiceSetTypeV1(OwnerMixinV1, ObjectTypeV1): choices_for: List[Annotated["CustomFieldTypeV1", strawberry.lazy('extras.graphql.types_v1')]] extra_choices: List[List[str]] | None @@ -124,7 +125,7 @@ class CustomFieldChoiceSetTypeV1(ObjectTypeV1): filters=CustomLinkFilterV1, pagination=True ) -class CustomLinkTypeV1(ObjectTypeV1): +class CustomLinkTypeV1(OwnerMixinV1, ObjectTypeV1): pass @@ -134,7 +135,7 @@ class CustomLinkTypeV1(ObjectTypeV1): filters=ExportTemplateFilterV1, pagination=True ) -class ExportTemplateTypeV1(SyncedDataMixinV1, ObjectTypeV1): +class ExportTemplateTypeV1(SyncedDataMixinV1, OwnerMixinV1, ObjectTypeV1): pass @@ -184,7 +185,7 @@ class NotificationGroupTypeV1(ObjectTypeV1): filters=SavedFilterFilterV1, pagination=True ) -class SavedFilterTypeV1(ObjectTypeV1): +class SavedFilterTypeV1(OwnerMixinV1, ObjectTypeV1): user: Annotated["UserTypeV1", strawberry.lazy('users.graphql.types_v1')] | None @@ -213,7 +214,7 @@ class TableConfigTypeV1(ObjectTypeV1): filters=TagFilterV1, pagination=True ) -class TagTypeV1(ObjectTypeV1): +class TagTypeV1(OwnerMixinV1, ObjectTypeV1): color: str object_types: List[ContentTypeTypeV1] diff --git a/netbox/ipam/graphql/types_v1.py b/netbox/ipam/graphql/types_v1.py index 7b5d2c652..091a2f215 100644 --- a/netbox/ipam/graphql/types_v1.py +++ b/netbox/ipam/graphql/types_v1.py @@ -8,7 +8,9 @@ from dcim.graphql.types_v1 import SiteTypeV1 from extras.graphql.mixins_v1 import ContactsMixinV1 from ipam import models from netbox.graphql.scalars import BigInt -from netbox.graphql.types_v1 import BaseObjectTypeV1, NetBoxObjectTypeV1, OrganizationalObjectTypeV1 +from netbox.graphql.types_v1 import ( + BaseObjectTypeV1, NetBoxObjectTypeV1, OrganizationalObjectTypeV1, PrimaryObjectTypeV1 +) from .filters_v1 import * from .mixins_v1 import IPAddressesMixinV1 @@ -76,7 +78,7 @@ class BaseIPAddressFamilyTypeV1: filters=ASNFilterV1, pagination=True ) -class ASNTypeV1(NetBoxObjectTypeV1, ContactsMixinV1): +class ASNTypeV1(ContactsMixinV1, PrimaryObjectTypeV1): asn: BigInt rir: Annotated["RIRTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None @@ -91,7 +93,7 @@ class ASNTypeV1(NetBoxObjectTypeV1, ContactsMixinV1): filters=ASNRangeFilterV1, pagination=True ) -class ASNRangeTypeV1(NetBoxObjectTypeV1): +class ASNRangeTypeV1(OrganizationalObjectTypeV1): start: BigInt end: BigInt rir: Annotated["RIRTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None @@ -104,7 +106,7 @@ class ASNRangeTypeV1(NetBoxObjectTypeV1): filters=AggregateFilterV1, pagination=True ) -class AggregateTypeV1(NetBoxObjectTypeV1, ContactsMixinV1, BaseIPAddressFamilyTypeV1): +class AggregateTypeV1(ContactsMixinV1, BaseIPAddressFamilyTypeV1, PrimaryObjectTypeV1): prefix: str rir: Annotated["RIRTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None @@ -116,8 +118,7 @@ class AggregateTypeV1(NetBoxObjectTypeV1, ContactsMixinV1, BaseIPAddressFamilyTy filters=FHRPGroupFilterV1, pagination=True ) -class FHRPGroupTypeV1(NetBoxObjectTypeV1, IPAddressesMixinV1): - +class FHRPGroupTypeV1(IPAddressesMixinV1, PrimaryObjectTypeV1): fhrpgroupassignment_set: List[Annotated["FHRPGroupAssignmentTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] @@ -144,7 +145,7 @@ class FHRPGroupAssignmentTypeV1(BaseObjectTypeV1): filters=IPAddressFilterV1, pagination=True ) -class IPAddressTypeV1(NetBoxObjectTypeV1, ContactsMixinV1, BaseIPAddressFamilyTypeV1): +class IPAddressTypeV1(ContactsMixinV1, BaseIPAddressFamilyTypeV1, PrimaryObjectTypeV1): address: str vrf: Annotated["VRFTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None @@ -169,7 +170,7 @@ class IPAddressTypeV1(NetBoxObjectTypeV1, ContactsMixinV1, BaseIPAddressFamilyTy filters=IPRangeFilterV1, pagination=True ) -class IPRangeTypeV1(NetBoxObjectTypeV1, ContactsMixinV1): +class IPRangeTypeV1(ContactsMixinV1, PrimaryObjectTypeV1): start_address: str end_address: str vrf: Annotated["VRFTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None @@ -183,7 +184,7 @@ class IPRangeTypeV1(NetBoxObjectTypeV1, ContactsMixinV1): filters=PrefixFilterV1, pagination=True ) -class PrefixTypeV1(NetBoxObjectTypeV1, ContactsMixinV1, BaseIPAddressFamilyTypeV1): +class PrefixTypeV1(ContactsMixinV1, BaseIPAddressFamilyTypeV1, PrimaryObjectTypeV1): prefix: str vrf: Annotated["VRFTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None @@ -232,7 +233,7 @@ class RoleTypeV1(OrganizationalObjectTypeV1): filters=RouteTargetFilterV1, pagination=True ) -class RouteTargetTypeV1(NetBoxObjectTypeV1): +class RouteTargetTypeV1(PrimaryObjectTypeV1): tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None importing_l2vpns: List[Annotated["L2VPNTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] @@ -247,7 +248,7 @@ class RouteTargetTypeV1(NetBoxObjectTypeV1): filters=ServiceFilterV1, pagination=True ) -class ServiceTypeV1(NetBoxObjectTypeV1, ContactsMixinV1): +class ServiceTypeV1(ContactsMixinV1, PrimaryObjectTypeV1): ports: List[int] ipaddresses: List[Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] @@ -266,7 +267,7 @@ class ServiceTypeV1(NetBoxObjectTypeV1, ContactsMixinV1): filters=ServiceTemplateFilterV1, pagination=True ) -class ServiceTemplateTypeV1(NetBoxObjectTypeV1): +class ServiceTemplateTypeV1(PrimaryObjectTypeV1): ports: List[int] @@ -276,7 +277,7 @@ class ServiceTemplateTypeV1(NetBoxObjectTypeV1): filters=VLANFilterV1, pagination=True ) -class VLANTypeV1(NetBoxObjectTypeV1): +class VLANTypeV1(PrimaryObjectTypeV1): site: Annotated["SiteTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None group: Annotated["VLANGroupTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None @@ -325,7 +326,7 @@ class VLANGroupTypeV1(OrganizationalObjectTypeV1): filters=VLANTranslationPolicyFilterV1, pagination=True ) -class VLANTranslationPolicyTypeV1(NetBoxObjectTypeV1): +class VLANTranslationPolicyTypeV1(PrimaryObjectTypeV1): rules: List[Annotated["VLANTranslationRuleTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] @@ -348,7 +349,7 @@ class VLANTranslationRuleTypeV1(NetBoxObjectTypeV1): filters=VRFFilterV1, pagination=True ) -class VRFTypeV1(NetBoxObjectTypeV1): +class VRFTypeV1(PrimaryObjectTypeV1): tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None interfaces: List[Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] diff --git a/netbox/netbox/graphql/types_v1.py b/netbox/netbox/graphql/types_v1.py index 66a3ac905..5fc776d8d 100644 --- a/netbox/netbox/graphql/types_v1.py +++ b/netbox/netbox/graphql/types_v1.py @@ -3,10 +3,10 @@ import strawberry_django from strawberry.types import Info from django.contrib.contenttypes.models import ContentType -from core.graphql.mixins import ChangelogMixin +from core.graphql.mixins_v1 import ChangelogMixinV1 from core.models import ObjectType as ObjectType_ -from extras.graphql.mixins import CustomFieldsMixin, JournalEntriesMixin, TagsMixin -from users.graphql.mixins import OwnerMixin +from extras.graphql.mixins_v1 import CustomFieldsMixinV1, JournalEntriesMixinV1, TagsMixinV1 +from users.graphql.mixins_v1 import OwnerMixinV1 __all__ = ( 'BaseObjectTypeV1', @@ -47,7 +47,7 @@ class BaseObjectTypeV1: class ObjectTypeV1( - ChangelogMixin, + ChangelogMixinV1, BaseObjectTypeV1 ): """ @@ -57,11 +57,11 @@ class ObjectTypeV1( class PrimaryObjectTypeV1( - ChangelogMixin, - CustomFieldsMixin, - JournalEntriesMixin, - TagsMixin, - OwnerMixin, + ChangelogMixinV1, + CustomFieldsMixinV1, + JournalEntriesMixinV1, + TagsMixinV1, + OwnerMixinV1, BaseObjectTypeV1 ): """ @@ -71,11 +71,11 @@ class PrimaryObjectTypeV1( class OrganizationalObjectTypeV1( - ChangelogMixin, - CustomFieldsMixin, - JournalEntriesMixin, - TagsMixin, - OwnerMixin, + ChangelogMixinV1, + CustomFieldsMixinV1, + JournalEntriesMixinV1, + TagsMixinV1, + OwnerMixinV1, BaseObjectTypeV1 ): """ @@ -85,11 +85,11 @@ class OrganizationalObjectTypeV1( class NestedGroupObjectTypeV1( - ChangelogMixin, - CustomFieldsMixin, - JournalEntriesMixin, - TagsMixin, - OwnerMixin, + ChangelogMixinV1, + CustomFieldsMixinV1, + JournalEntriesMixinV1, + TagsMixinV1, + OwnerMixinV1, BaseObjectTypeV1 ): """ @@ -99,10 +99,10 @@ class NestedGroupObjectTypeV1( class NetBoxObjectTypeV1( - ChangelogMixin, - CustomFieldsMixin, - JournalEntriesMixin, - TagsMixin, + ChangelogMixinV1, + CustomFieldsMixinV1, + JournalEntriesMixinV1, + TagsMixinV1, BaseObjectTypeV1 ): """ diff --git a/netbox/tenancy/graphql/types_v1.py b/netbox/tenancy/graphql/types_v1.py index cc4b774bc..82e7d7610 100644 --- a/netbox/tenancy/graphql/types_v1.py +++ b/netbox/tenancy/graphql/types_v1.py @@ -4,7 +4,9 @@ import strawberry import strawberry_django from extras.graphql.mixins_v1 import CustomFieldsMixinV1, TagsMixinV1, ContactsMixinV1 -from netbox.graphql.types_v1 import BaseObjectTypeV1, OrganizationalObjectTypeV1, NetBoxObjectTypeV1 +from netbox.graphql.types_v1 import ( + BaseObjectTypeV1, OrganizationalObjectTypeV1, PrimaryObjectTypeV1 +) from tenancy import models from .filters_v1 import * from .mixins_v1 import ContactAssignmentsMixinV1 @@ -57,7 +59,7 @@ __all__ = ( filters=TenantFilterV1, pagination=True ) -class TenantTypeV1(ContactsMixinV1, NetBoxObjectTypeV1): +class TenantTypeV1(ContactsMixinV1, PrimaryObjectTypeV1): group: Annotated['TenantGroupTypeV1', strawberry.lazy('tenancy.graphql.types_v1')] | None asns: List[Annotated['ASNTypeV1', strawberry.lazy('ipam.graphql.types_v1')]] circuits: List[Annotated['CircuitTypeV1', strawberry.lazy('circuits.graphql.types_v1')]] @@ -108,7 +110,7 @@ class TenantGroupTypeV1(OrganizationalObjectTypeV1): filters=ContactFilterV1, pagination=True ) -class ContactTypeV1(ContactAssignmentsMixinV1, NetBoxObjectTypeV1): +class ContactTypeV1(ContactAssignmentsMixinV1, PrimaryObjectTypeV1): groups: List[Annotated['ContactGroupTypeV1', strawberry.lazy('tenancy.graphql.types_v1')]] diff --git a/netbox/users/graphql/filters_v1.py b/netbox/users/graphql/filters_v1.py index 1b08ed6fc..951c666ae 100644 --- a/netbox/users/graphql/filters_v1.py +++ b/netbox/users/graphql/filters_v1.py @@ -10,6 +10,8 @@ from users import models __all__ = ( 'GroupFilterV1', + 'OwnerFilterV1', + 'OwnerGroupFilterV1', 'UserFilterV1', ) @@ -32,3 +34,24 @@ class UserFilterV1(BaseObjectTypeFilterMixinV1): last_login: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() groups: Annotated['GroupFilterV1', strawberry.lazy('users.graphql.filters_v1')] | None = ( strawberry_django.filter_field()) + + +@strawberry_django.filter_type(models.Owner, lookups=True) +class OwnerFilterV1(BaseObjectTypeFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + group: Annotated['OwnerGroupFilterV1', strawberry.lazy('users.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + user_groups: Annotated['GroupFilterV1', strawberry.lazy('users.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + users: Annotated['UserFilterV1', strawberry.lazy('users.graphql.filters_v1')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter_type(models.OwnerGroup, lookups=True) +class OwnerGroupFilterV1(BaseObjectTypeFilterMixinV1): + name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/users/graphql/mixins_v1.py b/netbox/users/graphql/mixins_v1.py new file mode 100644 index 000000000..b2fb22e6d --- /dev/null +++ b/netbox/users/graphql/mixins_v1.py @@ -0,0 +1,15 @@ +from typing import Annotated, TYPE_CHECKING + +import strawberry + +if TYPE_CHECKING: + from users.graphql.types_v1 import OwnerTypeV1 + +__all__ = ( + 'OwnerMixinV1', +) + + +@strawberry.type +class OwnerMixinV1: + owner: Annotated['OwnerTypeV1', strawberry.lazy('users.graphql.types_v1')] | None diff --git a/netbox/users/graphql/schema_v1.py b/netbox/users/graphql/schema_v1.py index 69c3b9d57..b5389f35e 100644 --- a/netbox/users/graphql/schema_v1.py +++ b/netbox/users/graphql/schema_v1.py @@ -13,3 +13,9 @@ class UsersQueryV1: user: UserTypeV1 = strawberry_django.field() user_list: List[UserTypeV1] = strawberry_django.field() + + owner_group: OwnerGroupTypeV1 = strawberry_django.field() + owner_group_list: List[OwnerGroupTypeV1] = strawberry_django.field() + + owner: OwnerTypeV1 = strawberry_django.field() + owner_list: List[OwnerTypeV1] = strawberry_django.field() diff --git a/netbox/users/graphql/types_v1.py b/netbox/users/graphql/types_v1.py index 3b48431d0..16b0aecde 100644 --- a/netbox/users/graphql/types_v1.py +++ b/netbox/users/graphql/types_v1.py @@ -3,11 +3,13 @@ from typing import List import strawberry_django from netbox.graphql.types_v1 import BaseObjectTypeV1 -from users.models import Group, User +from users.models import Group, Owner, OwnerGroup, User from .filters_v1 import * __all__ = ( 'GroupTypeV1', + 'OwnerGroupTypeV1', + 'OwnerTypeV1', 'UserTypeV1', ) @@ -32,3 +34,23 @@ class GroupTypeV1(BaseObjectTypeV1): ) class UserTypeV1(BaseObjectTypeV1): groups: List[GroupTypeV1] + + +@strawberry_django.type( + OwnerGroup, + fields=['id', 'name', 'description'], + filters=OwnerGroupFilterV1, + pagination=True +) +class OwnerGroupTypeV1(BaseObjectTypeV1): + pass + + +@strawberry_django.type( + Owner, + fields=['id', 'group', 'name', 'description', 'user_groups', 'users'], + filters=OwnerFilterV1, + pagination=True +) +class OwnerTypeV1(BaseObjectTypeV1): + group: OwnerGroupTypeV1 | None diff --git a/netbox/virtualization/graphql/types_v1.py b/netbox/virtualization/graphql/types_v1.py index 079fa7b29..ba6add3cb 100644 --- a/netbox/virtualization/graphql/types_v1.py +++ b/netbox/virtualization/graphql/types_v1.py @@ -6,7 +6,8 @@ import strawberry_django from extras.graphql.mixins_v1 import ConfigContextMixinV1, ContactsMixinV1 from ipam.graphql.mixins_v1 import IPAddressesMixinV1, VLANGroupsMixinV1 from netbox.graphql.scalars import BigInt -from netbox.graphql.types_v1 import OrganizationalObjectTypeV1, NetBoxObjectTypeV1 +from netbox.graphql.types_v1 import OrganizationalObjectTypeV1, NetBoxObjectTypeV1, PrimaryObjectTypeV1 +from users.graphql.mixins_v1 import OwnerMixinV1 from virtualization import models from .filters_v1 import * @@ -36,7 +37,7 @@ __all__ = ( @strawberry.type -class ComponentTypeV1(NetBoxObjectTypeV1): +class ComponentTypeV1(OwnerMixinV1, NetBoxObjectTypeV1): """ Base type for device/VM components """ @@ -49,7 +50,7 @@ class ComponentTypeV1(NetBoxObjectTypeV1): filters=ClusterFilterV1, pagination=True ) -class ClusterTypeV1(ContactsMixinV1, VLANGroupsMixinV1, NetBoxObjectTypeV1): +class ClusterTypeV1(ContactsMixinV1, VLANGroupsMixinV1, PrimaryObjectTypeV1): type: Annotated["ClusterTypeTypeV1", strawberry.lazy('virtualization.graphql.types_v1')] | None group: Annotated["ClusterGroupTypeV1", strawberry.lazy('virtualization.graphql.types_v1')] | None tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None @@ -94,7 +95,7 @@ class ClusterTypeTypeV1(OrganizationalObjectTypeV1): filters=VirtualMachineFilterV1, pagination=True ) -class VirtualMachineTypeV1(ConfigContextMixinV1, ContactsMixinV1, NetBoxObjectTypeV1): +class VirtualMachineTypeV1(ConfigContextMixinV1, ContactsMixinV1, PrimaryObjectTypeV1): interface_count: BigInt virtual_disk_count: BigInt interface_count: BigInt diff --git a/netbox/vpn/graphql/types_v1.py b/netbox/vpn/graphql/types_v1.py index 7812f88bb..6872cf6e2 100644 --- a/netbox/vpn/graphql/types_v1.py +++ b/netbox/vpn/graphql/types_v1.py @@ -4,7 +4,7 @@ import strawberry import strawberry_django from extras.graphql.mixins_v1 import ContactsMixinV1, CustomFieldsMixinV1, TagsMixinV1 -from netbox.graphql.types_v1 import ObjectTypeV1, OrganizationalObjectTypeV1, NetBoxObjectTypeV1 +from netbox.graphql.types_v1 import ObjectTypeV1, OrganizationalObjectTypeV1, NetBoxObjectTypeV1, PrimaryObjectTypeV1 from vpn import models from .filters_v1 import * @@ -58,7 +58,7 @@ class TunnelTerminationTypeV1(CustomFieldsMixinV1, TagsMixinV1, ObjectTypeV1): filters=TunnelFilterV1, pagination=True ) -class TunnelTypeV1(ContactsMixinV1, NetBoxObjectTypeV1): +class TunnelTypeV1(ContactsMixinV1, PrimaryObjectTypeV1): group: Annotated["TunnelGroupTypeV1", strawberry.lazy('vpn.graphql.types_v1')] | None ipsec_profile: Annotated["IPSecProfileTypeV1", strawberry.lazy('vpn.graphql.types_v1')] | None tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None @@ -72,8 +72,7 @@ class TunnelTypeV1(ContactsMixinV1, NetBoxObjectTypeV1): filters=IKEProposalFilterV1, pagination=True ) -class IKEProposalTypeV1(OrganizationalObjectTypeV1): - +class IKEProposalTypeV1(PrimaryObjectTypeV1): ike_policies: List[Annotated["IKEPolicyTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] @@ -95,7 +94,7 @@ class IKEPolicyTypeV1(OrganizationalObjectTypeV1): filters=IPSecProposalFilterV1, pagination=True ) -class IPSecProposalTypeV1(OrganizationalObjectTypeV1): +class IPSecProposalTypeV1(PrimaryObjectTypeV1): ipsec_policies: List[Annotated["IPSecPolicyTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] @@ -131,7 +130,7 @@ class IPSecProfileTypeV1(OrganizationalObjectTypeV1): filters=L2VPNFilterV1, pagination=True ) -class L2VPNTypeV1(ContactsMixinV1, NetBoxObjectTypeV1): +class L2VPNTypeV1(ContactsMixinV1, PrimaryObjectTypeV1): tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None export_targets: List[Annotated["RouteTargetTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] diff --git a/netbox/wireless/graphql/types_v1.py b/netbox/wireless/graphql/types_v1.py index b774f8f28..2c0fbe12f 100644 --- a/netbox/wireless/graphql/types_v1.py +++ b/netbox/wireless/graphql/types_v1.py @@ -3,7 +3,7 @@ from typing import Annotated, List, TYPE_CHECKING, Union import strawberry import strawberry_django -from netbox.graphql.types_v1 import OrganizationalObjectTypeV1, NetBoxObjectTypeV1 +from netbox.graphql.types_v1 import PrimaryObjectTypeV1, NestedGroupObjectTypeV1 from wireless import models from .filters_v1 import * @@ -27,7 +27,7 @@ __all__ = ( filters=WirelessLANGroupFilterV1, pagination=True ) -class WirelessLANGroupTypeV1(OrganizationalObjectTypeV1): +class WirelessLANGroupTypeV1(NestedGroupObjectTypeV1): parent: Annotated["WirelessLANGroupTypeV1", strawberry.lazy('wireless.graphql.types_v1')] | None wireless_lans: List[Annotated["WirelessLANTypeV1", strawberry.lazy('wireless.graphql.types_v1')]] @@ -40,7 +40,7 @@ class WirelessLANGroupTypeV1(OrganizationalObjectTypeV1): filters=WirelessLANFilterV1, pagination=True ) -class WirelessLANTypeV1(NetBoxObjectTypeV1): +class WirelessLANTypeV1(PrimaryObjectTypeV1): group: Annotated["WirelessLANGroupTypeV1", strawberry.lazy('wireless.graphql.types_v1')] | None vlan: Annotated["VLANTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None @@ -63,7 +63,7 @@ class WirelessLANTypeV1(NetBoxObjectTypeV1): filters=WirelessLinkFilterV1, pagination=True ) -class WirelessLinkTypeV1(NetBoxObjectTypeV1): +class WirelessLinkTypeV1(PrimaryObjectTypeV1): interface_a: Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] interface_b: Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None From 47ac506d5cb9db3605e814f4534e27ace58ab6ee Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 27 Oct 2025 16:32:09 -0400 Subject: [PATCH 089/125] Add a test to validate versioned GraphQL types --- netbox/netbox/tests/test_graphql.py | 50 +++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/netbox/netbox/tests/test_graphql.py b/netbox/netbox/tests/test_graphql.py index ca231526f..f5d69b03e 100644 --- a/netbox/netbox/tests/test_graphql.py +++ b/netbox/netbox/tests/test_graphql.py @@ -1,12 +1,15 @@ import json +import strawberry from django.test import override_settings from django.urls import reverse from rest_framework import status +from strawberry.types.lazy_type import LazyType from core.models import ObjectType from dcim.choices import LocationStatusChoices from dcim.models import Site, Location +from netbox.graphql.schema import QueryV1, QueryV2 from users.models import ObjectPermission from utilities.testing import disable_warnings, APITestCase, TestCase @@ -45,6 +48,53 @@ class GraphQLTestCase(TestCase): class GraphQLAPITestCase(APITestCase): + def test_versioned_types(self): + """ + Check that the GraphQL types defined for each version of the schema (V1 and V2) are correct. + """ + schemas = ( + (1, QueryV1), + (2, QueryV2), + ) + + def _get_class_name(field): + try: + if type(field.type) is strawberry.types.base.StrawberryList: + # Skip scalars + if field.type.of_type in (str, int): + return + if type(field.type.of_type) is LazyType: + return field.type.of_type.type_name + return field.type.of_type.__name__ + if hasattr(field.type, 'name'): + return field.type.__name__ + except AttributeError: + # Unknown field type + return + + def _check_version(class_name, version): + if version == 1: + self.assertTrue(class_name.endswith('V1'), f"{class_name} (v1) is not a V1 type") + elif version == 2: + self.assertFalse(class_name.endswith('V1'), f"{class_name} (v2) is a V1 type") + + for version, query in schemas: + schema = strawberry.Schema(query=query) + query_type = schema.get_type_by_name(query.__name__) + + # Iterate through root fields + for field in query_type.fields: + # Check for V1 suffix on class names + if type_class := _get_class_name(field): + _check_version(type_class, version) + + # Iterate through nested fields + subquery_type = schema.get_type_by_name(type_class) + for subfield in subquery_type.fields: + # Check for V1 suffix on class names + if type_class := _get_class_name(subfield): + _check_version(type_class, version) + @override_settings(LOGIN_REQUIRED=True) def test_graphql_filter_objects(self): """ From 5585b410f810a1cd5553a094901569a82f9cbd25 Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Tue, 18 Nov 2025 20:32:45 -0500 Subject: [PATCH 090/125] Remove all V1 files --- netbox/circuits/graphql/filter_mixins_v1.py | 19 - netbox/circuits/graphql/filters_v1.py | 228 ---- netbox/circuits/graphql/schema_v1.py | 42 - netbox/circuits/graphql/types_v1.py | 191 --- netbox/core/graphql/filter_mixins_v1.py | 36 - netbox/core/graphql/filters_v1.py | 89 -- netbox/core/graphql/mixins_v1.py | 35 - netbox/core/graphql/schema_v1.py | 15 - netbox/core/graphql/types_v1.py | 55 - netbox/dcim/graphql/filter_mixins_v1.py | 155 --- netbox/dcim/graphql/filters_v1.py | 1020 ----------------- netbox/dcim/graphql/gfk_mixins_v1.py | 137 --- netbox/dcim/graphql/mixins_v1.py | 43 - netbox/dcim/graphql/schema_v1.py | 138 --- netbox/dcim/graphql/types_v1.py | 903 --------------- netbox/extras/graphql/filter_mixins_v1.py | 52 - netbox/extras/graphql/filters_v1.py | 357 ------ netbox/extras/graphql/mixins_v1.py | 62 - netbox/extras/graphql/schema_v1.py | 60 - netbox/extras/graphql/types_v1.py | 240 ---- netbox/ipam/graphql/filter_mixins_v1.py | 25 - netbox/ipam/graphql/filters_v1.py | 392 ------- netbox/ipam/graphql/mixins_v1.py | 18 - netbox/ipam/graphql/schema_v1.py | 63 - netbox/ipam/graphql/types_v1.py | 361 ------ netbox/netbox/configuration_testing.py | 3 - netbox/netbox/graphql/filter_mixins_v1.py | 104 -- netbox/netbox/graphql/schema.py | 57 +- netbox/netbox/graphql/types_v1.py | 133 --- netbox/netbox/graphql/utils.py | 16 - netbox/netbox/urls.py | 9 +- netbox/tenancy/graphql/filter_mixins_v1.py | 38 - netbox/tenancy/graphql/filters_v1.py | 210 ---- netbox/tenancy/graphql/mixins_v1.py | 12 - netbox/tenancy/graphql/schema_v1.py | 27 - netbox/tenancy/graphql/types_v1.py | 149 --- netbox/users/graphql/filters_v1.py | 57 - netbox/users/graphql/mixins_v1.py | 15 - netbox/users/graphql/schema_v1.py | 21 - netbox/users/graphql/types_v1.py | 56 - .../graphql/filter_mixins_v1.py | 28 - netbox/virtualization/graphql/filters_v1.py | 170 --- netbox/virtualization/graphql/schema_v1.py | 27 - netbox/virtualization/graphql/types_v1.py | 147 --- netbox/vpn/graphql/filters_v1.py | 192 ---- netbox/vpn/graphql/schema_v1.py | 39 - netbox/vpn/graphql/types_v1.py | 156 --- netbox/wireless/graphql/filter_mixins_v1.py | 26 - netbox/wireless/graphql/filters_v1.py | 72 -- netbox/wireless/graphql/schema_v1.py | 18 - netbox/wireless/graphql/types_v1.py | 71 -- 51 files changed, 6 insertions(+), 6583 deletions(-) delete mode 100644 netbox/circuits/graphql/filter_mixins_v1.py delete mode 100644 netbox/circuits/graphql/filters_v1.py delete mode 100644 netbox/circuits/graphql/schema_v1.py delete mode 100644 netbox/circuits/graphql/types_v1.py delete mode 100644 netbox/core/graphql/filter_mixins_v1.py delete mode 100644 netbox/core/graphql/filters_v1.py delete mode 100644 netbox/core/graphql/mixins_v1.py delete mode 100644 netbox/core/graphql/schema_v1.py delete mode 100644 netbox/core/graphql/types_v1.py delete mode 100644 netbox/dcim/graphql/filter_mixins_v1.py delete mode 100644 netbox/dcim/graphql/filters_v1.py delete mode 100644 netbox/dcim/graphql/gfk_mixins_v1.py delete mode 100644 netbox/dcim/graphql/mixins_v1.py delete mode 100644 netbox/dcim/graphql/schema_v1.py delete mode 100644 netbox/dcim/graphql/types_v1.py delete mode 100644 netbox/extras/graphql/filter_mixins_v1.py delete mode 100644 netbox/extras/graphql/filters_v1.py delete mode 100644 netbox/extras/graphql/mixins_v1.py delete mode 100644 netbox/extras/graphql/schema_v1.py delete mode 100644 netbox/extras/graphql/types_v1.py delete mode 100644 netbox/ipam/graphql/filter_mixins_v1.py delete mode 100644 netbox/ipam/graphql/filters_v1.py delete mode 100644 netbox/ipam/graphql/mixins_v1.py delete mode 100644 netbox/ipam/graphql/schema_v1.py delete mode 100644 netbox/ipam/graphql/types_v1.py delete mode 100644 netbox/netbox/graphql/filter_mixins_v1.py delete mode 100644 netbox/netbox/graphql/types_v1.py delete mode 100644 netbox/netbox/graphql/utils.py delete mode 100644 netbox/tenancy/graphql/filter_mixins_v1.py delete mode 100644 netbox/tenancy/graphql/filters_v1.py delete mode 100644 netbox/tenancy/graphql/mixins_v1.py delete mode 100644 netbox/tenancy/graphql/schema_v1.py delete mode 100644 netbox/tenancy/graphql/types_v1.py delete mode 100644 netbox/users/graphql/filters_v1.py delete mode 100644 netbox/users/graphql/mixins_v1.py delete mode 100644 netbox/users/graphql/schema_v1.py delete mode 100644 netbox/users/graphql/types_v1.py delete mode 100644 netbox/virtualization/graphql/filter_mixins_v1.py delete mode 100644 netbox/virtualization/graphql/filters_v1.py delete mode 100644 netbox/virtualization/graphql/schema_v1.py delete mode 100644 netbox/virtualization/graphql/types_v1.py delete mode 100644 netbox/vpn/graphql/filters_v1.py delete mode 100644 netbox/vpn/graphql/schema_v1.py delete mode 100644 netbox/vpn/graphql/types_v1.py delete mode 100644 netbox/wireless/graphql/filter_mixins_v1.py delete mode 100644 netbox/wireless/graphql/filters_v1.py delete mode 100644 netbox/wireless/graphql/schema_v1.py delete mode 100644 netbox/wireless/graphql/types_v1.py diff --git a/netbox/circuits/graphql/filter_mixins_v1.py b/netbox/circuits/graphql/filter_mixins_v1.py deleted file mode 100644 index 15ebce2d4..000000000 --- a/netbox/circuits/graphql/filter_mixins_v1.py +++ /dev/null @@ -1,19 +0,0 @@ -from dataclasses import dataclass -from typing import Annotated, TYPE_CHECKING - -import strawberry -import strawberry_django - -from netbox.graphql.filter_mixins_v1 import OrganizationalModelFilterMixinV1 - -if TYPE_CHECKING: - from netbox.graphql.enums import ColorEnum - -__all__ = ( - 'BaseCircuitTypeFilterMixinV1', -) - - -@dataclass -class BaseCircuitTypeFilterMixinV1(OrganizationalModelFilterMixinV1): - color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() diff --git a/netbox/circuits/graphql/filters_v1.py b/netbox/circuits/graphql/filters_v1.py deleted file mode 100644 index caf4b7c62..000000000 --- a/netbox/circuits/graphql/filters_v1.py +++ /dev/null @@ -1,228 +0,0 @@ -from datetime import date -from typing import Annotated, TYPE_CHECKING - -import strawberry -import strawberry_django -from strawberry.scalars import ID -from strawberry_django import FilterLookup, DateFilterLookup - -from circuits import models -from core.graphql.filter_mixins_v1 import BaseObjectTypeFilterMixinV1, ChangeLogFilterMixinV1 -from dcim.graphql.filter_mixins_v1 import CabledObjectModelFilterMixinV1 -from extras.graphql.filter_mixins_v1 import CustomFieldsFilterMixinV1, TagsFilterMixinV1 -from netbox.graphql.filter_mixins_v1 import ( - DistanceFilterMixinV1, - ImageAttachmentFilterMixinV1, - OrganizationalModelFilterMixinV1, - PrimaryModelFilterMixinV1, -) -from tenancy.graphql.filter_mixins_v1 import ContactFilterMixinV1, TenancyFilterMixinV1 -from .filter_mixins_v1 import BaseCircuitTypeFilterMixinV1 - -if TYPE_CHECKING: - from core.graphql.filters_v1 import ContentTypeFilterV1 - from dcim.graphql.filters_v1 import ( - InterfaceFilterV1, LocationFilterV1, RegionFilterV1, SiteFilterV1, SiteGroupFilterV1 - ) - from ipam.graphql.filters_v1 import ASNFilterV1 - from netbox.graphql.filter_lookups import IntegerLookup - from .enums import * - -__all__ = ( - 'CircuitFilterV1', - 'CircuitGroupAssignmentFilterV1', - 'CircuitGroupFilterV1', - 'CircuitTerminationFilterV1', - 'CircuitTypeFilterV1', - 'ProviderFilterV1', - 'ProviderAccountFilterV1', - 'ProviderNetworkFilterV1', - 'VirtualCircuitFilterV1', - 'VirtualCircuitTerminationFilterV1', - 'VirtualCircuitTypeFilterV1', -) - - -@strawberry_django.filter_type(models.CircuitTermination, lookups=True) -class CircuitTerminationFilterV1( - BaseObjectTypeFilterMixinV1, - CustomFieldsFilterMixinV1, - TagsFilterMixinV1, - ChangeLogFilterMixinV1, - CabledObjectModelFilterMixinV1, -): - circuit: Annotated['CircuitFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - term_side: Annotated['CircuitTerminationSideEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - termination_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - termination_id: ID | None = strawberry_django.filter_field() - port_speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - upstream_speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - xconnect_id: FilterLookup[str] | None = strawberry_django.filter_field() - pp_info: FilterLookup[str] | None = strawberry_django.filter_field() - description: FilterLookup[str] | None = strawberry_django.filter_field() - - # Cached relations - _provider_network: Annotated['ProviderNetworkFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( - strawberry_django.filter_field(name='provider_network') - ) - _location: Annotated['LocationFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field(name='location') - ) - _region: Annotated['RegionFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field(name='region') - ) - _site_group: Annotated['SiteGroupFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field(name='site_group') - ) - _site: Annotated['SiteFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field(name='site') - ) - - -@strawberry_django.filter_type(models.Circuit, lookups=True) -class CircuitFilterV1( - ContactFilterMixinV1, - ImageAttachmentFilterMixinV1, - DistanceFilterMixinV1, - TenancyFilterMixinV1, - PrimaryModelFilterMixinV1 -): - cid: FilterLookup[str] | None = strawberry_django.filter_field() - provider: Annotated['ProviderFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - provider_id: ID | None = strawberry_django.filter_field() - provider_account: Annotated['ProviderAccountFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - provider_account_id: ID | None = strawberry_django.filter_field() - type: Annotated['CircuitTypeFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - type_id: ID | None = strawberry_django.filter_field() - status: Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - install_date: DateFilterLookup[date] | None = strawberry_django.filter_field() - termination_date: DateFilterLookup[date] | None = strawberry_django.filter_field() - commit_rate: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - terminations: Annotated['CircuitTerminationFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.CircuitType, lookups=True) -class CircuitTypeFilterV1(BaseCircuitTypeFilterMixinV1): - pass - - -@strawberry_django.filter_type(models.CircuitGroup, lookups=True) -class CircuitGroupFilterV1(TenancyFilterMixinV1, OrganizationalModelFilterMixinV1): - pass - - -@strawberry_django.filter_type(models.CircuitGroupAssignment, lookups=True) -class CircuitGroupAssignmentFilterV1( - BaseObjectTypeFilterMixinV1, CustomFieldsFilterMixinV1, TagsFilterMixinV1, ChangeLogFilterMixinV1 -): - member_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - member_id: ID | None = strawberry_django.filter_field() - group: Annotated['CircuitGroupFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - group_id: ID | None = strawberry_django.filter_field() - priority: Annotated['CircuitPriorityEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.Provider, lookups=True) -class ProviderFilterV1(ContactFilterMixinV1, PrimaryModelFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - slug: FilterLookup[str] | None = strawberry_django.filter_field() - asns: Annotated['ASNFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = strawberry_django.filter_field() - circuits: Annotated['CircuitFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.ProviderAccount, lookups=True) -class ProviderAccountFilterV1(ContactFilterMixinV1, PrimaryModelFilterMixinV1): - provider: Annotated['ProviderFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - provider_id: ID | None = strawberry_django.filter_field() - account: FilterLookup[str] | None = strawberry_django.filter_field() - name: FilterLookup[str] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.ProviderNetwork, lookups=True) -class ProviderNetworkFilterV1(PrimaryModelFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - provider: Annotated['ProviderFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - provider_id: ID | None = strawberry_django.filter_field() - service_id: FilterLookup[str] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.VirtualCircuitType, lookups=True) -class VirtualCircuitTypeFilterV1(BaseCircuitTypeFilterMixinV1): - pass - - -@strawberry_django.filter_type(models.VirtualCircuit, lookups=True) -class VirtualCircuitFilterV1(TenancyFilterMixinV1, PrimaryModelFilterMixinV1): - cid: FilterLookup[str] | None = strawberry_django.filter_field() - provider_network: Annotated['ProviderNetworkFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - provider_network_id: ID | None = strawberry_django.filter_field() - provider_account: Annotated['ProviderAccountFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - provider_account_id: ID | None = strawberry_django.filter_field() - type: Annotated['VirtualCircuitTypeFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - type_id: ID | None = strawberry_django.filter_field() - status: Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - group_assignments: Annotated[ - 'CircuitGroupAssignmentFilterV1', strawberry.lazy('circuits.graphql.filters_v1') - ] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.VirtualCircuitTermination, lookups=True) -class VirtualCircuitTerminationFilterV1( - BaseObjectTypeFilterMixinV1, CustomFieldsFilterMixinV1, TagsFilterMixinV1, ChangeLogFilterMixinV1 -): - virtual_circuit: Annotated['VirtualCircuitFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - virtual_circuit_id: ID | None = strawberry_django.filter_field() - role: Annotated['VirtualCircuitTerminationRoleEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - interface: Annotated['InterfaceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - interface_id: ID | None = strawberry_django.filter_field() - description: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/circuits/graphql/schema_v1.py b/netbox/circuits/graphql/schema_v1.py deleted file mode 100644 index 1134978f9..000000000 --- a/netbox/circuits/graphql/schema_v1.py +++ /dev/null @@ -1,42 +0,0 @@ -from typing import List - -import strawberry -import strawberry_django - -from .types_v1 import * - - -@strawberry.type(name="Query") -class CircuitsQueryV1: - circuit: CircuitTypeV1 = strawberry_django.field() - circuit_list: List[CircuitTypeV1] = strawberry_django.field() - - circuit_termination: CircuitTerminationTypeV1 = strawberry_django.field() - circuit_termination_list: List[CircuitTerminationTypeV1] = strawberry_django.field() - - circuit_type: CircuitTypeTypeV1 = strawberry_django.field() - circuit_type_list: List[CircuitTypeTypeV1] = strawberry_django.field() - - circuit_group: CircuitGroupTypeV1 = strawberry_django.field() - circuit_group_list: List[CircuitGroupTypeV1] = strawberry_django.field() - - circuit_group_assignment: CircuitGroupAssignmentTypeV1 = strawberry_django.field() - circuit_group_assignment_list: List[CircuitGroupAssignmentTypeV1] = strawberry_django.field() - - provider: ProviderTypeV1 = strawberry_django.field() - provider_list: List[ProviderTypeV1] = strawberry_django.field() - - provider_account: ProviderAccountTypeV1 = strawberry_django.field() - provider_account_list: List[ProviderAccountTypeV1] = strawberry_django.field() - - provider_network: ProviderNetworkTypeV1 = strawberry_django.field() - provider_network_list: List[ProviderNetworkTypeV1] = strawberry_django.field() - - virtual_circuit: VirtualCircuitTypeV1 = strawberry_django.field() - virtual_circuit_list: List[VirtualCircuitTypeV1] = strawberry_django.field() - - virtual_circuit_termination: VirtualCircuitTerminationTypeV1 = strawberry_django.field() - virtual_circuit_termination_list: List[VirtualCircuitTerminationTypeV1] = strawberry_django.field() - - virtual_circuit_type: VirtualCircuitTypeTypeV1 = strawberry_django.field() - virtual_circuit_type_list: List[VirtualCircuitTypeTypeV1] = strawberry_django.field() diff --git a/netbox/circuits/graphql/types_v1.py b/netbox/circuits/graphql/types_v1.py deleted file mode 100644 index c9f9234dc..000000000 --- a/netbox/circuits/graphql/types_v1.py +++ /dev/null @@ -1,191 +0,0 @@ -from typing import Annotated, List, TYPE_CHECKING, Union - -import strawberry -import strawberry_django - -from circuits import models -from dcim.graphql.mixins_v1 import CabledObjectMixinV1 -from extras.graphql.mixins_v1 import ContactsMixinV1, CustomFieldsMixinV1, TagsMixinV1 -from netbox.graphql.types_v1 import ( - BaseObjectTypeV1, ObjectTypeV1, OrganizationalObjectTypeV1, PrimaryObjectTypeV1 -) -from tenancy.graphql.types_v1 import TenantTypeV1 -from .filters_v1 import * - -if TYPE_CHECKING: - from dcim.graphql.types_v1 import InterfaceTypeV1, LocationTypeV1, RegionTypeV1, SiteGroupTypeV1, SiteTypeV1 - from ipam.graphql.types_v1 import ASNTypeV1 - -__all__ = ( - 'CircuitGroupAssignmentTypeV1', - 'CircuitGroupTypeV1', - 'CircuitTerminationTypeV1', - 'CircuitTypeV1', - 'CircuitTypeTypeV1', - 'ProviderTypeV1', - 'ProviderAccountTypeV1', - 'ProviderNetworkTypeV1', - 'VirtualCircuitTerminationTypeV1', - 'VirtualCircuitTypeV1', - 'VirtualCircuitTypeTypeV1', -) - - -@strawberry_django.type( - models.Provider, - fields='__all__', - filters=ProviderFilterV1, - pagination=True -) -class ProviderTypeV1(ContactsMixinV1, PrimaryObjectTypeV1): - - networks: List[Annotated["ProviderNetworkTypeV1", strawberry.lazy('circuits.graphql.types_v1')]] - circuits: List[Annotated["CircuitTypeV1", strawberry.lazy('circuits.graphql.types_v1')]] - asns: List[Annotated["ASNTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] - accounts: List[Annotated["ProviderAccountTypeV1", strawberry.lazy('circuits.graphql.types_v1')]] - - -@strawberry_django.type( - models.ProviderAccount, - fields='__all__', - filters=ProviderAccountFilterV1, - pagination=True -) -class ProviderAccountTypeV1(ContactsMixinV1, PrimaryObjectTypeV1): - provider: Annotated["ProviderTypeV1", strawberry.lazy('circuits.graphql.types_v1')] - - circuits: List[Annotated["CircuitTypeV1", strawberry.lazy('circuits.graphql.types_v1')]] - - -@strawberry_django.type( - models.ProviderNetwork, - fields='__all__', - filters=ProviderNetworkFilterV1, - pagination=True -) -class ProviderNetworkTypeV1(PrimaryObjectTypeV1): - provider: Annotated["ProviderTypeV1", strawberry.lazy('circuits.graphql.types_v1')] - - circuit_terminations: List[Annotated["CircuitTerminationTypeV1", strawberry.lazy('circuits.graphql.types_v1')]] - - -@strawberry_django.type( - models.CircuitTermination, - exclude=['termination_type', 'termination_id', '_location', '_region', '_site', '_site_group', '_provider_network'], - filters=CircuitTerminationFilterV1, - pagination=True -) -class CircuitTerminationTypeV1(CustomFieldsMixinV1, TagsMixinV1, CabledObjectMixinV1, ObjectTypeV1): - circuit: Annotated["CircuitTypeV1", strawberry.lazy('circuits.graphql.types_v1')] - - @strawberry_django.field - def termination(self) -> Annotated[Union[ - Annotated["LocationTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["RegionTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["SiteGroupTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["SiteTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["ProviderNetworkTypeV1", strawberry.lazy('circuits.graphql.types_v1')], - ], strawberry.union("CircuitTerminationTerminationTypeV1")] | None: - return self.termination - - -@strawberry_django.type( - models.CircuitType, - fields='__all__', - filters=CircuitTypeFilterV1, - pagination=True -) -class CircuitTypeTypeV1(OrganizationalObjectTypeV1): - color: str - - circuits: List[Annotated["CircuitTypeV1", strawberry.lazy('circuits.graphql.types_v1')]] - - -@strawberry_django.type( - models.Circuit, - fields='__all__', - filters=CircuitFilterV1, - pagination=True -) -class CircuitTypeV1(PrimaryObjectTypeV1, ContactsMixinV1): - provider: ProviderTypeV1 - provider_account: ProviderAccountTypeV1 | None - termination_a: CircuitTerminationTypeV1 | None - termination_z: CircuitTerminationTypeV1 | None - type: CircuitTypeTypeV1 - tenant: TenantTypeV1 | None - - terminations: List[CircuitTerminationTypeV1] - - -@strawberry_django.type( - models.CircuitGroup, - fields='__all__', - filters=CircuitGroupFilterV1, - pagination=True -) -class CircuitGroupTypeV1(OrganizationalObjectTypeV1): - tenant: TenantTypeV1 | None - - -@strawberry_django.type( - models.CircuitGroupAssignment, - exclude=['member_type', 'member_id'], - filters=CircuitGroupAssignmentFilterV1, - pagination=True -) -class CircuitGroupAssignmentTypeV1(TagsMixinV1, BaseObjectTypeV1): - group: Annotated["CircuitGroupTypeV1", strawberry.lazy('circuits.graphql.types_v1')] - - @strawberry_django.field - def member(self) -> Annotated[Union[ - Annotated["CircuitTypeV1", strawberry.lazy('circuits.graphql.types_v1')], - Annotated["VirtualCircuitTypeV1", strawberry.lazy('circuits.graphql.types_v1')], - ], strawberry.union("CircuitGroupAssignmentMemberTypeV1")] | None: - return self.member - - -@strawberry_django.type( - models.VirtualCircuitType, - fields='__all__', - filters=VirtualCircuitTypeFilterV1, - pagination=True -) -class VirtualCircuitTypeTypeV1(OrganizationalObjectTypeV1): - color: str - - virtual_circuits: List[Annotated["VirtualCircuitTypeV1", strawberry.lazy('circuits.graphql.types_v1')]] - - -@strawberry_django.type( - models.VirtualCircuitTermination, - fields='__all__', - filters=VirtualCircuitTerminationFilterV1, - pagination=True -) -class VirtualCircuitTerminationTypeV1(CustomFieldsMixinV1, TagsMixinV1, ObjectTypeV1): - virtual_circuit: Annotated[ - "VirtualCircuitTypeV1", - strawberry.lazy('circuits.graphql.types_v1') - ] = strawberry_django.field(select_related=["virtual_circuit"]) - interface: Annotated[ - "InterfaceTypeV1", - strawberry.lazy('dcim.graphql.types_v1') - ] = strawberry_django.field(select_related=["interface"]) - - -@strawberry_django.type( - models.VirtualCircuit, - fields='__all__', - filters=VirtualCircuitFilterV1, - pagination=True -) -class VirtualCircuitTypeV1(PrimaryObjectTypeV1): - provider_network: ProviderNetworkTypeV1 = strawberry_django.field(select_related=["provider_network"]) - provider_account: ProviderAccountTypeV1 | None - type: Annotated["VirtualCircuitTypeTypeV1", strawberry.lazy('circuits.graphql.types_v1')] = strawberry_django.field( - select_related=["type"] - ) - tenant: TenantTypeV1 | None - - terminations: List[VirtualCircuitTerminationTypeV1] diff --git a/netbox/core/graphql/filter_mixins_v1.py b/netbox/core/graphql/filter_mixins_v1.py deleted file mode 100644 index 58c39705f..000000000 --- a/netbox/core/graphql/filter_mixins_v1.py +++ /dev/null @@ -1,36 +0,0 @@ -from dataclasses import dataclass -from datetime import datetime -from typing import Annotated, TYPE_CHECKING - -import strawberry -import strawberry_django -from strawberry import ID -from strawberry_django import DatetimeFilterLookup - -if TYPE_CHECKING: - from .filters_v1 import * - -__all__ = ( - 'BaseFilterMixinV1', - 'BaseObjectTypeFilterMixinV1', - 'ChangeLogFilterMixinV1', -) - - -# @strawberry.input -class BaseFilterMixinV1: ... - - -@dataclass -class BaseObjectTypeFilterMixinV1(BaseFilterMixinV1): - id: ID | None = strawberry.UNSET - - -@dataclass -class ChangeLogFilterMixinV1(BaseFilterMixinV1): - id: ID | None = strawberry.UNSET - changelog: Annotated['ObjectChangeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() - last_updated: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() diff --git a/netbox/core/graphql/filters_v1.py b/netbox/core/graphql/filters_v1.py deleted file mode 100644 index c75de0c75..000000000 --- a/netbox/core/graphql/filters_v1.py +++ /dev/null @@ -1,89 +0,0 @@ -from datetime import datetime -from typing import Annotated, TYPE_CHECKING - -import strawberry -import strawberry_django -from django.contrib.contenttypes.models import ContentType as DjangoContentType -from strawberry.scalars import ID -from strawberry_django import DatetimeFilterLookup, FilterLookup - -from core import models -from core.graphql.filter_mixins_v1 import BaseFilterMixinV1 -from netbox.graphql.filter_mixins import PrimaryModelFilterMixin - -if TYPE_CHECKING: - from netbox.graphql.filter_lookups import IntegerLookup, JSONFilter - from users.graphql.filters import UserFilter - -__all__ = ( - 'DataFileFilterV1', - 'DataSourceFilterV1', - 'ObjectChangeFilterV1', - 'ContentTypeFilterV1', -) - - -@strawberry_django.filter_type(models.DataFile, lookups=True) -class DataFileFilterV1(BaseFilterMixinV1): - id: ID | None = strawberry_django.filter_field() - created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() - last_updated: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() - source: Annotated['DataSourceFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - source_id: ID | None = strawberry_django.filter_field() - path: FilterLookup[str] | None = strawberry_django.filter_field() - size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - hash: FilterLookup[str] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.DataSource, lookups=True) -class DataSourceFilterV1(PrimaryModelFilterMixin): - name: FilterLookup[str] | None = strawberry_django.filter_field() - type: FilterLookup[str] | None = strawberry_django.filter_field() - source_url: FilterLookup[str] | None = strawberry_django.filter_field() - status: FilterLookup[str] | None = strawberry_django.filter_field() - enabled: FilterLookup[bool] | None = strawberry_django.filter_field() - ignore_rules: FilterLookup[str] | None = strawberry_django.filter_field() - parameters: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - last_synced: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() - datafiles: Annotated['DataFileFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.ObjectChange, lookups=True) -class ObjectChangeFilterV1(BaseFilterMixinV1): - id: ID | None = strawberry_django.filter_field() - time: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() - user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() - user_name: FilterLookup[str] | None = strawberry_django.filter_field() - request_id: FilterLookup[str] | None = strawberry_django.filter_field() - action: FilterLookup[str] | None = strawberry_django.filter_field() - changed_object_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - changed_object_type_id: ID | None = strawberry_django.filter_field() - changed_object_id: ID | None = strawberry_django.filter_field() - related_object_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - related_object_id: ID | None = strawberry_django.filter_field() - object_repr: FilterLookup[str] | None = strawberry_django.filter_field() - prechange_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - postchange_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(DjangoContentType, lookups=True) -class ContentTypeFilterV1(BaseFilterMixinV1): - id: ID | None = strawberry_django.filter_field() - app_label: FilterLookup[str] | None = strawberry_django.filter_field() - model: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/core/graphql/mixins_v1.py b/netbox/core/graphql/mixins_v1.py deleted file mode 100644 index 722068cdd..000000000 --- a/netbox/core/graphql/mixins_v1.py +++ /dev/null @@ -1,35 +0,0 @@ -from typing import Annotated, List, TYPE_CHECKING - -import strawberry -import strawberry_django -from django.contrib.contenttypes.models import ContentType -from strawberry.types import Info - -from core.models import ObjectChange - -if TYPE_CHECKING: - from core.graphql.types_v1 import DataFileTypeV1, DataSourceTypeV1, ObjectChangeTypeV1 - -__all__ = ( - 'ChangelogMixinV1', - 'SyncedDataMixinV1', -) - - -@strawberry.type -class ChangelogMixinV1: - - @strawberry_django.field - def changelog(self, info: Info) -> List[Annotated['ObjectChangeTypeV1', strawberry.lazy('.types_v1')]]: # noqa: F821 - content_type = ContentType.objects.get_for_model(self) - object_changes = ObjectChange.objects.filter( - changed_object_type=content_type, - changed_object_id=self.pk - ) - return object_changes.restrict(info.context.request.user, 'view') - - -@strawberry.type -class SyncedDataMixinV1: - data_source: Annotated['DataSourceTypeV1', strawberry.lazy('core.graphql.types_v1')] | None - data_file: Annotated['DataFileTypeV1', strawberry.lazy('core.graphql.types_v1')] | None diff --git a/netbox/core/graphql/schema_v1.py b/netbox/core/graphql/schema_v1.py deleted file mode 100644 index b94d14a70..000000000 --- a/netbox/core/graphql/schema_v1.py +++ /dev/null @@ -1,15 +0,0 @@ -from typing import List - -import strawberry -import strawberry_django - -from .types_v1 import * - - -@strawberry.type(name="Query") -class CoreQueryV1: - data_file: DataFileTypeV1 = strawberry_django.field() - data_file_list: List[DataFileTypeV1] = strawberry_django.field() - - data_source: DataSourceTypeV1 = strawberry_django.field() - data_source_list: List[DataSourceTypeV1] = strawberry_django.field() diff --git a/netbox/core/graphql/types_v1.py b/netbox/core/graphql/types_v1.py deleted file mode 100644 index 7dc8c0e3b..000000000 --- a/netbox/core/graphql/types_v1.py +++ /dev/null @@ -1,55 +0,0 @@ -from typing import Annotated, List - -import strawberry -import strawberry_django -from django.contrib.contenttypes.models import ContentType as DjangoContentType - -from core import models -from netbox.graphql.types_v1 import BaseObjectTypeV1, PrimaryObjectTypeV1 -from .filters_v1 import * - -__all__ = ( - 'ContentTypeV1', - 'DataFileTypeV1', - 'DataSourceTypeV1', - 'ObjectChangeTypeV1', -) - - -@strawberry_django.type( - models.DataFile, - exclude=['data',], - filters=DataFileFilterV1, - pagination=True -) -class DataFileTypeV1(BaseObjectTypeV1): - source: Annotated["DataSourceTypeV1", strawberry.lazy('core.graphql.types_v1')] - - -@strawberry_django.type( - models.DataSource, - fields='__all__', - filters=DataSourceFilterV1, - pagination=True -) -class DataSourceTypeV1(PrimaryObjectTypeV1): - datafiles: List[Annotated["DataFileTypeV1", strawberry.lazy('core.graphql.types_v1')]] - - -@strawberry_django.type( - models.ObjectChange, - fields='__all__', - filters=ObjectChangeFilterV1, - pagination=True -) -class ObjectChangeTypeV1(BaseObjectTypeV1): - pass - - -@strawberry_django.type( - DjangoContentType, - fields='__all__', - pagination=True -) -class ContentTypeV1: - pass diff --git a/netbox/dcim/graphql/filter_mixins_v1.py b/netbox/dcim/graphql/filter_mixins_v1.py deleted file mode 100644 index 55f329497..000000000 --- a/netbox/dcim/graphql/filter_mixins_v1.py +++ /dev/null @@ -1,155 +0,0 @@ -from dataclasses import dataclass -from typing import Annotated, TYPE_CHECKING - -import strawberry -import strawberry_django -from strawberry import ID -from strawberry_django import FilterLookup - -from core.graphql.filter_mixins_v1 import BaseFilterMixinV1, ChangeLogFilterMixinV1 -from core.graphql.filters_v1 import ContentTypeFilterV1 -from netbox.graphql.filter_mixins_v1 import NetBoxModelFilterMixinV1, PrimaryModelFilterMixinV1, WeightFilterMixinV1 -from .enums import * - -if TYPE_CHECKING: - from netbox.graphql.filter_lookups import IntegerLookup - from extras.graphql.filters_v1 import ConfigTemplateFilterV1 - from ipam.graphql.filters_v1 import VLANFilterV1, VLANTranslationPolicyFilterV1 - from .filters_v1 import * - -__all__ = ( - 'CabledObjectModelFilterMixinV1', - 'ComponentModelFilterMixinV1', - 'ComponentTemplateFilterMixinV1', - 'InterfaceBaseFilterMixinV1', - 'ModularComponentModelFilterMixinV1', - 'ModularComponentTemplateFilterMixinV1', - 'RackBaseFilterMixinV1', - 'RenderConfigFilterMixinV1', - 'ScopedFilterMixinV1', -) - - -@dataclass -class ScopedFilterMixinV1(BaseFilterMixinV1): - scope_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - scope_id: ID | None = strawberry_django.filter_field() - - -@dataclass -class ComponentModelFilterMixinV1(NetBoxModelFilterMixinV1): - device: Annotated['DeviceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - device_id: ID | None = strawberry_django.filter_field() - name: FilterLookup[str] | None = strawberry_django.filter_field() - label: FilterLookup[str] | None = strawberry_django.filter_field() - description: FilterLookup[str] | None = strawberry_django.filter_field() - - -@dataclass -class ModularComponentModelFilterMixinV1(ComponentModelFilterMixinV1): - module: Annotated['ModuleFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - module_id: ID | None = strawberry_django.filter_field() - inventory_items: Annotated['InventoryItemFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@dataclass -class CabledObjectModelFilterMixinV1(BaseFilterMixinV1): - cable: Annotated['CableFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - cable_id: ID | None = strawberry_django.filter_field() - cable_end: CableEndEnum | None = strawberry_django.filter_field() - mark_connected: FilterLookup[bool] | None = strawberry_django.filter_field() - - -@dataclass -class ComponentTemplateFilterMixinV1(ChangeLogFilterMixinV1): - device_type: Annotated['DeviceTypeFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - device_type_id: ID | None = strawberry_django.filter_field() - name: FilterLookup[str] | None = strawberry_django.filter_field() - label: FilterLookup[str] | None = strawberry_django.filter_field() - description: FilterLookup[str] | None = strawberry_django.filter_field() - - -@dataclass -class ModularComponentTemplateFilterMixinV1(ComponentTemplateFilterMixinV1): - module_type: Annotated['ModuleTypeFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@dataclass -class RenderConfigFilterMixinV1(BaseFilterMixinV1): - config_template: Annotated['ConfigTemplateFilterV1', strawberry.lazy('extras.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - config_template_id: ID | None = strawberry_django.filter_field() - - -@dataclass -class InterfaceBaseFilterMixinV1(BaseFilterMixinV1): - enabled: FilterLookup[bool] | None = strawberry_django.filter_field() - mtu: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - mode: InterfaceModeEnum | None = strawberry_django.filter_field() - bridge: Annotated['InterfaceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - bridge_id: ID | None = strawberry_django.filter_field() - untagged_vlan: Annotated['VLANFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - tagged_vlans: Annotated['VLANFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - qinq_svlan: Annotated['VLANFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - vlan_translation_policy: Annotated[ - 'VLANTranslationPolicyFilterV1', strawberry.lazy('ipam.graphql.filters_v1') - ] | None = strawberry_django.filter_field() - primary_mac_address: Annotated['MACAddressFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - primary_mac_address_id: ID | None = strawberry_django.filter_field() - - -@dataclass -class RackBaseFilterMixinV1(WeightFilterMixinV1, PrimaryModelFilterMixinV1): - width: Annotated['RackWidthEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() - u_height: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - starting_unit: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - desc_units: FilterLookup[bool] | None = strawberry_django.filter_field() - outer_width: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - outer_height: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - outer_depth: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - outer_unit: Annotated['RackDimensionUnitEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - mounting_depth: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - max_weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) diff --git a/netbox/dcim/graphql/filters_v1.py b/netbox/dcim/graphql/filters_v1.py deleted file mode 100644 index be87c3618..000000000 --- a/netbox/dcim/graphql/filters_v1.py +++ /dev/null @@ -1,1020 +0,0 @@ -from typing import Annotated, TYPE_CHECKING - -from django.db.models import Q -import strawberry -import strawberry_django -from strawberry.scalars import ID -from strawberry_django import FilterLookup - -from core.graphql.filter_mixins_v1 import ChangeLogFilterMixinV1 -from dcim import models -from dcim.constants import * -from dcim.graphql.enums import InterfaceKindEnum -from extras.graphql.filter_mixins_v1 import ConfigContextFilterMixinV1 -from netbox.graphql.filter_mixins_v1 import ( - PrimaryModelFilterMixinV1, - OrganizationalModelFilterMixinV1, - NestedGroupModelFilterMixinV1, - ImageAttachmentFilterMixinV1, - WeightFilterMixinV1, -) -from tenancy.graphql.filter_mixins_v1 import TenancyFilterMixinV1, ContactFilterMixinV1 -from .filter_mixins_v1 import ( - CabledObjectModelFilterMixinV1, - ComponentModelFilterMixinV1, - ComponentTemplateFilterMixinV1, - InterfaceBaseFilterMixinV1, - ModularComponentModelFilterMixinV1, - ModularComponentTemplateFilterMixinV1, - RackBaseFilterMixinV1, - RenderConfigFilterMixinV1, -) - -if TYPE_CHECKING: - from core.graphql.filters_v1 import ContentTypeFilterV1 - from extras.graphql.filters_v1 import ConfigTemplateFilterV1, ImageAttachmentFilterV1 - from ipam.graphql.filters_v1 import ( - ASNFilterV1, FHRPGroupAssignmentFilterV1, IPAddressFilterV1, PrefixFilterV1, VLANGroupFilterV1, VRFFilterV1, - ) - from netbox.graphql.enums import ColorEnum - from netbox.graphql.filter_lookups import FloatLookup, IntegerArrayLookup, IntegerLookup, TreeNodeFilter - from users.graphql.filters_v1 import UserFilterV1 - from virtualization.graphql.filters_v1 import ClusterFilterV1 - from vpn.graphql.filters_v1 import L2VPNFilterV1, TunnelTerminationFilterV1 - from wireless.graphql.enums import WirelessChannelEnum, WirelessRoleEnum - from wireless.graphql.filters_v1 import WirelessLANFilterV1, WirelessLinkFilterV1 - from .enums import * - -__all__ = ( - 'CableFilterV1', - 'CableTerminationFilterV1', - 'ConsolePortFilterV1', - 'ConsolePortTemplateFilterV1', - 'ConsoleServerPortFilterV1', - 'ConsoleServerPortTemplateFilterV1', - 'DeviceFilterV1', - 'DeviceBayFilterV1', - 'DeviceBayTemplateFilterV1', - 'DeviceRoleFilterV1', - 'DeviceTypeFilterV1', - 'FrontPortFilterV1', - 'FrontPortTemplateFilterV1', - 'InterfaceFilterV1', - 'InterfaceTemplateFilterV1', - 'InventoryItemFilterV1', - 'InventoryItemRoleFilterV1', - 'InventoryItemTemplateFilterV1', - 'LocationFilterV1', - 'MACAddressFilterV1', - 'ManufacturerFilterV1', - 'ModuleFilterV1', - 'ModuleBayFilterV1', - 'ModuleBayTemplateFilterV1', - 'ModuleTypeFilterV1', - 'ModuleTypeProfileFilterV1', - 'PlatformFilterV1', - 'PowerFeedFilterV1', - 'PowerOutletFilterV1', - 'PowerOutletTemplateFilterV1', - 'PowerPanelFilterV1', - 'PowerPortFilterV1', - 'PowerPortTemplateFilterV1', - 'RackFilterV1', - 'RackReservationFilterV1', - 'RackRoleFilterV1', - 'RackTypeFilterV1', - 'RearPortFilterV1', - 'RearPortTemplateFilterV1', - 'RegionFilterV1', - 'SiteFilterV1', - 'SiteGroupFilterV1', - 'VirtualChassisFilterV1', - 'VirtualDeviceContextFilterV1', -) - - -@strawberry_django.filter_type(models.Cable, lookups=True) -class CableFilterV1(PrimaryModelFilterMixinV1, TenancyFilterMixinV1): - type: Annotated['CableTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() - status: Annotated['LinkStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() - label: FilterLookup[str] | None = strawberry_django.filter_field() - color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() - length: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - length_unit: Annotated['CableLengthUnitEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - terminations: Annotated['CableTerminationFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.CableTermination, lookups=True) -class CableTerminationFilterV1(ChangeLogFilterMixinV1): - cable: Annotated['CableFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - cable_id: ID | None = strawberry_django.filter_field() - cable_end: Annotated['CableEndEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - termination_type: Annotated['CableTerminationFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - termination_id: ID | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.ConsolePort, lookups=True) -class ConsolePortFilterV1(ModularComponentModelFilterMixinV1, CabledObjectModelFilterMixinV1): - type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - speed: Annotated['ConsolePortSpeedEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.ConsolePortTemplate, lookups=True) -class ConsolePortTemplateFilterV1(ModularComponentTemplateFilterMixinV1): - type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.ConsoleServerPort, lookups=True) -class ConsoleServerPortFilterV1(ModularComponentModelFilterMixinV1, CabledObjectModelFilterMixinV1): - type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - speed: Annotated['ConsolePortSpeedEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.ConsoleServerPortTemplate, lookups=True) -class ConsoleServerPortTemplateFilterV1(ModularComponentTemplateFilterMixinV1): - type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.Device, lookups=True) -class DeviceFilterV1( - ContactFilterMixinV1, - TenancyFilterMixinV1, - ImageAttachmentFilterMixinV1, - RenderConfigFilterMixinV1, - ConfigContextFilterMixinV1, - PrimaryModelFilterMixinV1, -): - device_type: Annotated['DeviceTypeFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - device_type_id: ID | None = strawberry_django.filter_field() - role: Annotated['DeviceRoleFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - role_id: ID | None = strawberry_django.filter_field() - platform: Annotated['PlatformFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - name: FilterLookup[str] | None = strawberry_django.filter_field() - serial: FilterLookup[str] | None = strawberry_django.filter_field() - asset_tag: FilterLookup[str] | None = strawberry_django.filter_field() - site: Annotated['SiteFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - site_id: ID | None = strawberry_django.filter_field() - location: Annotated['LocationFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - rack: Annotated['RackFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - rack_id: ID | None = strawberry_django.filter_field() - position: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - face: Annotated['DeviceFaceEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() - status: Annotated['DeviceStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - airflow: Annotated['DeviceAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - primary_ip4: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - primary_ip4_id: ID | None = strawberry_django.filter_field() - primary_ip6: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - primary_ip6_id: ID | None = strawberry_django.filter_field() - oob_ip: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - oob_ip_id: ID | None = strawberry_django.filter_field() - cluster: Annotated['ClusterFilterV1', strawberry.lazy('virtualization.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - cluster_id: ID | None = strawberry_django.filter_field() - virtual_chassis: Annotated['VirtualChassisFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - virtual_chassis_id: ID | None = strawberry_django.filter_field() - vc_position: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - vc_priority: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - latitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - longitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - console_ports: Annotated['ConsolePortFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - console_server_ports: Annotated['ConsoleServerPortFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - power_outlets: Annotated['PowerOutletFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - power_ports: Annotated['PowerPortFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - interfaces: Annotated['InterfaceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - front_ports: Annotated['FrontPortFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - rear_ports: Annotated['RearPortFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - device_bays: Annotated['DeviceBayFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - module_bays: Annotated['ModuleBayFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - modules: Annotated['ModuleFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - console_port_count: FilterLookup[int] | None = strawberry_django.filter_field() - console_server_port_count: FilterLookup[int] | None = strawberry_django.filter_field() - power_port_count: FilterLookup[int] | None = strawberry_django.filter_field() - power_outlet_count: FilterLookup[int] | None = strawberry_django.filter_field() - interface_count: FilterLookup[int] | None = strawberry_django.filter_field() - front_port_count: FilterLookup[int] | None = strawberry_django.filter_field() - rear_port_count: FilterLookup[int] | None = strawberry_django.filter_field() - device_bay_count: FilterLookup[int] | None = strawberry_django.filter_field() - module_bay_count: FilterLookup[int] | None = strawberry_django.filter_field() - inventory_item_count: FilterLookup[int] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.DeviceBay, lookups=True) -class DeviceBayFilterV1(ComponentModelFilterMixinV1): - installed_device: Annotated['DeviceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - installed_device_id: ID | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.DeviceBayTemplate, lookups=True) -class DeviceBayTemplateFilterV1(ComponentTemplateFilterMixinV1): - pass - - -@strawberry_django.filter_type(models.InventoryItemTemplate, lookups=True) -class InventoryItemTemplateFilterV1(ComponentTemplateFilterMixinV1): - parent: Annotated['InventoryItemTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - component_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - component_id: ID | None = strawberry_django.filter_field() - role: Annotated['InventoryItemRoleFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - role_id: ID | None = strawberry_django.filter_field() - manufacturer: Annotated['ManufacturerFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - manufacturer_id: ID | None = strawberry_django.filter_field() - part_id: FilterLookup[str] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.DeviceRole, lookups=True) -class DeviceRoleFilterV1(OrganizationalModelFilterMixinV1, RenderConfigFilterMixinV1): - color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() - vm_role: FilterLookup[bool] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.DeviceType, lookups=True) -class DeviceTypeFilterV1(ImageAttachmentFilterMixinV1, PrimaryModelFilterMixinV1, WeightFilterMixinV1): - manufacturer: Annotated['ManufacturerFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - manufacturer_id: ID | None = strawberry_django.filter_field() - model: FilterLookup[str] | None = strawberry_django.filter_field() - slug: FilterLookup[str] | None = strawberry_django.filter_field() - default_platform: Annotated['PlatformFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - default_platform_id: ID | None = strawberry_django.filter_field() - part_number: FilterLookup[str] | None = strawberry_django.filter_field() - u_height: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - exclude_from_utilization: FilterLookup[bool] | None = strawberry_django.filter_field() - is_full_depth: FilterLookup[bool] | None = strawberry_django.filter_field() - subdevice_role: Annotated['SubdeviceRoleEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - airflow: Annotated['DeviceAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - front_image: Annotated['ImageAttachmentFilterV1', strawberry.lazy('extras.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - rear_image: Annotated['ImageAttachmentFilterV1', strawberry.lazy('extras.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - console_port_templates: ( - Annotated['ConsolePortTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None - ) = strawberry_django.filter_field() - console_server_port_templates: ( - Annotated['ConsoleServerPortTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None - ) = strawberry_django.filter_field() - power_port_templates: ( - Annotated['PowerPortTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None - ) = strawberry_django.filter_field() - power_outlet_templates: ( - Annotated['PowerOutletTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None - ) = strawberry_django.filter_field() - interface_templates: ( - Annotated['InterfaceTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None - ) = strawberry_django.filter_field() - front_port_templates: ( - Annotated['FrontPortTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None - ) = strawberry_django.filter_field() - rear_port_templates: ( - Annotated['RearPortTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None - ) = strawberry_django.filter_field() - device_bay_templates: ( - Annotated['DeviceBayTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None - ) = strawberry_django.filter_field() - module_bay_templates: ( - Annotated['ModuleBayTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None - ) = strawberry_django.filter_field() - inventory_item_templates: ( - Annotated['InventoryItemTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None - ) = strawberry_django.filter_field() - console_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field() - console_server_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field() - power_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field() - power_outlet_template_count: FilterLookup[int] | None = strawberry_django.filter_field() - interface_template_count: FilterLookup[int] | None = strawberry_django.filter_field() - front_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field() - rear_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field() - device_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field() - module_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field() - inventory_item_template_count: FilterLookup[int] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.FrontPort, lookups=True) -class FrontPortFilterV1(ModularComponentModelFilterMixinV1, CabledObjectModelFilterMixinV1): - type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() - color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() - rear_port: Annotated['RearPortFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - rear_port_id: ID | None = strawberry_django.filter_field() - rear_port_position: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.FrontPortTemplate, lookups=True) -class FrontPortTemplateFilterV1(ModularComponentTemplateFilterMixinV1): - type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() - color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() - rear_port: Annotated['RearPortTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - rear_port_id: ID | None = strawberry_django.filter_field() - rear_port_position: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.MACAddress, lookups=True) -class MACAddressFilterV1(PrimaryModelFilterMixinV1): - mac_address: FilterLookup[str] | None = strawberry_django.filter_field() - assigned_object_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - assigned_object_id: ID | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.Interface, lookups=True) -class InterfaceFilterV1(ModularComponentModelFilterMixinV1, InterfaceBaseFilterMixinV1, CabledObjectModelFilterMixinV1): - vcdcs: Annotated['VirtualDeviceContextFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - lag: Annotated['InterfaceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - lag_id: ID | None = strawberry_django.filter_field() - type: Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - mgmt_only: FilterLookup[bool] | None = strawberry_django.filter_field() - speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - duplex: Annotated['InterfaceDuplexEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - wwn: FilterLookup[str] | None = strawberry_django.filter_field() - parent: Annotated['InterfaceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - parent_id: ID | None = strawberry_django.filter_field() - rf_role: Annotated['WirelessRoleEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - rf_channel: Annotated['WirelessChannelEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - rf_channel_frequency: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - rf_channel_width: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - tx_power: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - poe_mode: Annotated['InterfacePoEModeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - poe_type: Annotated['InterfacePoETypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - wireless_link: Annotated['WirelessLinkFilterV1', strawberry.lazy('wireless.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - wireless_link_id: ID | None = strawberry_django.filter_field() - wireless_lans: Annotated['WirelessLANFilterV1', strawberry.lazy('wireless.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - vrf: Annotated['VRFFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = strawberry_django.filter_field() - vrf_id: ID | None = strawberry_django.filter_field() - ip_addresses: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - mac_addresses: Annotated['MACAddressFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - fhrp_group_assignments: Annotated[ - 'FHRPGroupAssignmentFilterV1', strawberry.lazy('ipam.graphql.filters_v1') - ] | None = ( - strawberry_django.filter_field() - ) - tunnel_terminations: Annotated['TunnelTerminationFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - l2vpn_terminations: Annotated['L2VPNFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - @strawberry_django.filter_field - def connected(self, queryset, value: bool, prefix: str): - if value is True: - return queryset, Q(**{f"{prefix}_path__is_active": True}) - else: - return queryset, Q(**{f"{prefix}_path__isnull": True}) | Q(**{f"{prefix}_path__is_active": False}) - - @strawberry_django.filter_field - def kind( - self, - queryset, - value: Annotated['InterfaceKindEnum', strawberry.lazy('dcim.graphql.enums')], - prefix: str - ): - if value == InterfaceKindEnum.KIND_PHYSICAL: - return queryset, ~Q(**{f"{prefix}type__in": NONCONNECTABLE_IFACE_TYPES}) - elif value == InterfaceKindEnum.KIND_VIRTUAL: - return queryset, Q(**{f"{prefix}type__in": VIRTUAL_IFACE_TYPES}) - elif value == InterfaceKindEnum.KIND_WIRELESS: - return queryset, Q(**{f"{prefix}type__in": WIRELESS_IFACE_TYPES}) - - -@strawberry_django.filter_type(models.InterfaceTemplate, lookups=True) -class InterfaceTemplateFilterV1(ModularComponentTemplateFilterMixinV1): - type: Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - enabled: FilterLookup[bool] | None = strawberry_django.filter_field() - mgmt_only: FilterLookup[bool] | None = strawberry_django.filter_field() - bridge: Annotated['InterfaceTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - bridge_id: ID | None = strawberry_django.filter_field() - poe_mode: Annotated['InterfacePoEModeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - poe_type: Annotated['InterfacePoETypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - rf_role: Annotated['WirelessRoleEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.InventoryItem, lookups=True) -class InventoryItemFilterV1(ComponentModelFilterMixinV1): - parent: Annotated['InventoryItemFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - parent_id: ID | None = strawberry_django.filter_field() - component_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - component_id: ID | None = strawberry_django.filter_field() - status: Annotated['InventoryItemStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - role: Annotated['InventoryItemRoleFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - role_id: ID | None = strawberry_django.filter_field() - manufacturer: Annotated['ManufacturerFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - manufacturer_id: ID | None = strawberry_django.filter_field() - part_id: FilterLookup[str] | None = strawberry_django.filter_field() - serial: FilterLookup[str] | None = strawberry_django.filter_field() - asset_tag: FilterLookup[str] | None = strawberry_django.filter_field() - discovered: FilterLookup[bool] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.InventoryItemRole, lookups=True) -class InventoryItemRoleFilterV1(OrganizationalModelFilterMixinV1): - color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.Location, lookups=True) -class LocationFilterV1( - ContactFilterMixinV1, ImageAttachmentFilterMixinV1, TenancyFilterMixinV1, NestedGroupModelFilterMixinV1 -): - site: Annotated['SiteFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - site_id: ID | None = strawberry_django.filter_field() - status: Annotated['LocationStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - facility: FilterLookup[str] | None = strawberry_django.filter_field() - prefixes: Annotated['PrefixFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - vlan_groups: Annotated['VLANGroupFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.Manufacturer, lookups=True) -class ManufacturerFilterV1(ContactFilterMixinV1, OrganizationalModelFilterMixinV1): - pass - - -@strawberry_django.filter_type(models.Module, lookups=True) -class ModuleFilterV1(PrimaryModelFilterMixinV1, ConfigContextFilterMixinV1): - device: Annotated['DeviceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - device_id: ID | None = strawberry_django.filter_field() - module_bay: Annotated['ModuleBayFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - module_bay_id: ID | None = strawberry_django.filter_field() - module_type: Annotated['ModuleTypeFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - module_type_id: ID | None = strawberry_django.filter_field() - status: Annotated['ModuleStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - serial: FilterLookup[str] | None = strawberry_django.filter_field() - asset_tag: FilterLookup[str] | None = strawberry_django.filter_field() - console_ports: Annotated['ConsolePortFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - console_server_ports: Annotated['ConsoleServerPortFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - power_outlets: Annotated['PowerOutletFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - power_ports: Annotated['PowerPortFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - interfaces: Annotated['InterfaceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - front_ports: Annotated['FrontPortFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - rear_ports: Annotated['RearPortFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - device_bays: Annotated['DeviceBayFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - module_bays: Annotated['ModuleBayFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - modules: Annotated['ModuleFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.ModuleBay, lookups=True) -class ModuleBayFilterV1(ModularComponentModelFilterMixinV1): - parent: Annotated['ModuleBayFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - parent_id: ID | None = strawberry_django.filter_field() - position: FilterLookup[str] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.ModuleBayTemplate, lookups=True) -class ModuleBayTemplateFilterV1(ModularComponentTemplateFilterMixinV1): - position: FilterLookup[str] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.ModuleTypeProfile, lookups=True) -class ModuleTypeProfileFilterV1(PrimaryModelFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.ModuleType, lookups=True) -class ModuleTypeFilterV1(ImageAttachmentFilterMixinV1, PrimaryModelFilterMixinV1, WeightFilterMixinV1): - manufacturer: Annotated['ManufacturerFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - manufacturer_id: ID | None = strawberry_django.filter_field() - profile: Annotated['ModuleTypeProfileFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - profile_id: ID | None = strawberry_django.filter_field() - model: FilterLookup[str] | None = strawberry_django.filter_field() - part_number: FilterLookup[str] | None = strawberry_django.filter_field() - airflow: Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - console_port_templates: ( - Annotated['ConsolePortTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None - ) = strawberry_django.filter_field() - console_server_port_templates: ( - Annotated['ConsoleServerPortTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None - ) = strawberry_django.filter_field() - power_port_templates: ( - Annotated['PowerPortTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None - ) = strawberry_django.filter_field() - power_outlet_templates: ( - Annotated['PowerOutletTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None - ) = strawberry_django.filter_field() - interface_templates: ( - Annotated['InterfaceTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None - ) = strawberry_django.filter_field() - front_port_templates: ( - Annotated['FrontPortTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None - ) = strawberry_django.filter_field() - rear_port_templates: ( - Annotated['RearPortTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None - ) = strawberry_django.filter_field() - device_bay_templates: ( - Annotated['DeviceBayTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None - ) = strawberry_django.filter_field() - module_bay_templates: ( - Annotated['ModuleBayTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None - ) = strawberry_django.filter_field() - inventory_item_templates: ( - Annotated['InventoryItemTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None - ) = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.Platform, lookups=True) -class PlatformFilterV1(OrganizationalModelFilterMixinV1): - manufacturer: Annotated['ManufacturerFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - manufacturer_id: ID | None = strawberry_django.filter_field() - config_template: Annotated['ConfigTemplateFilterV1', strawberry.lazy('extras.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - config_template_id: ID | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.PowerFeed, lookups=True) -class PowerFeedFilterV1(CabledObjectModelFilterMixinV1, TenancyFilterMixinV1, PrimaryModelFilterMixinV1): - power_panel: Annotated['PowerPanelFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - power_panel_id: ID | None = strawberry_django.filter_field() - rack: Annotated['RackFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - rack_id: ID | None = strawberry_django.filter_field() - name: FilterLookup[str] | None = strawberry_django.filter_field() - status: Annotated['PowerFeedStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - type: Annotated['PowerFeedTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - supply: Annotated['PowerFeedSupplyEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - phase: Annotated['PowerFeedPhaseEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - voltage: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - amperage: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - max_utilization: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - available_power: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.PowerOutlet, lookups=True) -class PowerOutletFilterV1(ModularComponentModelFilterMixinV1, CabledObjectModelFilterMixinV1): - type: Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - power_port: Annotated['PowerPortFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - power_port_id: ID | None = strawberry_django.filter_field() - feed_leg: Annotated['PowerOutletFeedLegEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.PowerOutletTemplate, lookups=True) -class PowerOutletTemplateFilterV1(ModularComponentModelFilterMixinV1): - type: Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - power_port: Annotated['PowerPortTemplateFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - power_port_id: ID | None = strawberry_django.filter_field() - feed_leg: Annotated['PowerOutletFeedLegEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.PowerPanel, lookups=True) -class PowerPanelFilterV1(ContactFilterMixinV1, ImageAttachmentFilterMixinV1, PrimaryModelFilterMixinV1): - site: Annotated['SiteFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - site_id: ID | None = strawberry_django.filter_field() - location: Annotated['LocationFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - name: FilterLookup[str] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.PowerPort, lookups=True) -class PowerPortFilterV1(ModularComponentModelFilterMixinV1, CabledObjectModelFilterMixinV1): - type: Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - maximum_draw: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - allocated_draw: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.PowerPortTemplate, lookups=True) -class PowerPortTemplateFilterV1(ModularComponentTemplateFilterMixinV1): - type: Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - maximum_draw: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - allocated_draw: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.RackType, lookups=True) -class RackTypeFilterV1(RackBaseFilterMixinV1): - form_factor: Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - manufacturer: Annotated['ManufacturerFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - manufacturer_id: ID | None = strawberry_django.filter_field() - model: FilterLookup[str] | None = strawberry_django.filter_field() - slug: FilterLookup[str] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.Rack, lookups=True) -class RackFilterV1(ContactFilterMixinV1, ImageAttachmentFilterMixinV1, TenancyFilterMixinV1, RackBaseFilterMixinV1): - form_factor: Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - rack_type: Annotated['RackTypeFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - rack_type_id: ID | None = strawberry_django.filter_field() - name: FilterLookup[str] | None = strawberry_django.filter_field() - facility_id: FilterLookup[str] | None = strawberry_django.filter_field() - site: Annotated['SiteFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - site_id: ID | None = strawberry_django.filter_field() - location: Annotated['LocationFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - status: Annotated['RackStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() - role: Annotated['RackRoleFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - role_id: ID | None = strawberry_django.filter_field() - serial: FilterLookup[str] | None = strawberry_django.filter_field() - asset_tag: FilterLookup[str] | None = strawberry_django.filter_field() - airflow: Annotated['RackAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - vlan_groups: Annotated['VLANGroupFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.RackReservation, lookups=True) -class RackReservationFilterV1(TenancyFilterMixinV1, PrimaryModelFilterMixinV1): - rack: Annotated['RackFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - rack_id: ID | None = strawberry_django.filter_field() - units: Annotated['IntegerArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - user: Annotated['UserFilterV1', strawberry.lazy('users.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - user_id: ID | None = strawberry_django.filter_field() - description: FilterLookup[str] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.RackRole, lookups=True) -class RackRoleFilterV1(OrganizationalModelFilterMixinV1): - color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.RearPort, lookups=True) -class RearPortFilterV1(ModularComponentModelFilterMixinV1, CabledObjectModelFilterMixinV1): - type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() - color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() - positions: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.RearPortTemplate, lookups=True) -class RearPortTemplateFilterV1(ModularComponentTemplateFilterMixinV1): - type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() - color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() - positions: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.Region, lookups=True) -class RegionFilterV1(ContactFilterMixinV1, NestedGroupModelFilterMixinV1): - prefixes: Annotated['PrefixFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - vlan_groups: Annotated['VLANGroupFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.Site, lookups=True) -class SiteFilterV1(ContactFilterMixinV1, ImageAttachmentFilterMixinV1, TenancyFilterMixinV1, PrimaryModelFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - slug: FilterLookup[str] | None = strawberry_django.filter_field() - status: Annotated['SiteStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() - region: Annotated['RegionFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - region_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - group: Annotated['SiteGroupFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - facility: FilterLookup[str] | None = strawberry_django.filter_field() - asns: Annotated['ASNFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = strawberry_django.filter_field() - time_zone: FilterLookup[str] | None = strawberry_django.filter_field() - physical_address: FilterLookup[str] | None = strawberry_django.filter_field() - shipping_address: FilterLookup[str] | None = strawberry_django.filter_field() - latitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - longitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - prefixes: Annotated['PrefixFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - vlan_groups: Annotated['VLANGroupFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.SiteGroup, lookups=True) -class SiteGroupFilterV1(ContactFilterMixinV1, NestedGroupModelFilterMixinV1): - prefixes: Annotated['PrefixFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - vlan_groups: Annotated['VLANGroupFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.VirtualChassis, lookups=True) -class VirtualChassisFilterV1(PrimaryModelFilterMixinV1): - master: Annotated['DeviceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - master_id: ID | None = strawberry_django.filter_field() - name: FilterLookup[str] | None = strawberry_django.filter_field() - domain: FilterLookup[str] | None = strawberry_django.filter_field() - members: ( - Annotated['DeviceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None - ) = strawberry_django.filter_field() - member_count: FilterLookup[int] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.VirtualDeviceContext, lookups=True) -class VirtualDeviceContextFilterV1(TenancyFilterMixinV1, PrimaryModelFilterMixinV1): - device: Annotated['DeviceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - device_id: ID | None = strawberry_django.filter_field() - name: FilterLookup[str] | None = strawberry_django.filter_field() - status: Annotated['VirtualDeviceContextStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - identifier: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - primary_ip4: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - primary_ip4_id: ID | None = strawberry_django.filter_field() - primary_ip6: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - primary_ip6_id: ID | None = strawberry_django.filter_field() - comments: FilterLookup[str] | None = strawberry_django.filter_field() - interfaces: ( - Annotated['InterfaceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None - ) = strawberry_django.filter_field() diff --git a/netbox/dcim/graphql/gfk_mixins_v1.py b/netbox/dcim/graphql/gfk_mixins_v1.py deleted file mode 100644 index 378b3a916..000000000 --- a/netbox/dcim/graphql/gfk_mixins_v1.py +++ /dev/null @@ -1,137 +0,0 @@ -from strawberry.types import Info - -from circuits.graphql.types_v1 import CircuitTerminationTypeV1, ProviderNetworkTypeV1 -from circuits.models import CircuitTermination, ProviderNetwork -from dcim.graphql.types_v1 import ( - ConsolePortTemplateTypeV1, - ConsolePortTypeV1, - ConsoleServerPortTemplateTypeV1, - ConsoleServerPortTypeV1, - FrontPortTemplateTypeV1, - FrontPortTypeV1, - InterfaceTemplateTypeV1, - InterfaceTypeV1, - PowerFeedTypeV1, - PowerOutletTemplateTypeV1, - PowerOutletTypeV1, - PowerPortTemplateTypeV1, - PowerPortTypeV1, - RearPortTemplateTypeV1, - RearPortTypeV1, -) -from dcim.models import ( - ConsolePort, - ConsolePortTemplate, - ConsoleServerPort, - ConsoleServerPortTemplate, - FrontPort, - FrontPortTemplate, - Interface, - InterfaceTemplate, - PowerFeed, - PowerOutlet, - PowerOutletTemplate, - PowerPort, - PowerPortTemplate, - RearPort, - RearPortTemplate, -) - - -class InventoryItemTemplateComponentTypeV1: - class Meta: - types = ( - ConsolePortTemplateTypeV1, - ConsoleServerPortTemplateTypeV1, - FrontPortTemplateTypeV1, - InterfaceTemplateTypeV1, - PowerOutletTemplateTypeV1, - PowerPortTemplateTypeV1, - RearPortTemplateTypeV1, - ) - - @classmethod - def resolve_type(cls, instance, info: Info): - if type(instance) is ConsolePortTemplate: - return ConsolePortTemplateTypeV1 - if type(instance) is ConsoleServerPortTemplate: - return ConsoleServerPortTemplateTypeV1 - if type(instance) is FrontPortTemplate: - return FrontPortTemplateTypeV1 - if type(instance) is InterfaceTemplate: - return InterfaceTemplateTypeV1 - if type(instance) is PowerOutletTemplate: - return PowerOutletTemplateTypeV1 - if type(instance) is PowerPortTemplate: - return PowerPortTemplateTypeV1 - if type(instance) is RearPortTemplate: - return RearPortTemplateTypeV1 - - -class InventoryItemComponentTypeV1: - class Meta: - types = ( - ConsolePortTypeV1, - ConsoleServerPortTypeV1, - FrontPortTypeV1, - InterfaceTypeV1, - PowerOutletTypeV1, - PowerPortTypeV1, - RearPortTypeV1, - ) - - @classmethod - def resolve_type(cls, instance, info: Info): - if type(instance) is ConsolePort: - return ConsolePortTypeV1 - if type(instance) is ConsoleServerPort: - return ConsoleServerPortTypeV1 - if type(instance) is FrontPort: - return FrontPortTypeV1 - if type(instance) is Interface: - return InterfaceTypeV1 - if type(instance) is PowerOutlet: - return PowerOutletTypeV1 - if type(instance) is PowerPort: - return PowerPortTypeV1 - if type(instance) is RearPort: - return RearPortTypeV1 - - -class ConnectedEndpointTypeV1: - class Meta: - types = ( - CircuitTerminationTypeV1, - ConsolePortTypeV1, - ConsoleServerPortTypeV1, - FrontPortTypeV1, - InterfaceTypeV1, - PowerFeedTypeV1, - PowerOutletTypeV1, - PowerPortTypeV1, - ProviderNetworkTypeV1, - RearPortTypeV1, - ) - - @classmethod - def resolve_type(cls, instance, info: Info): - if type(instance) is CircuitTermination: - return CircuitTerminationTypeV1 - if type(instance) is ConsolePort: - return ConsolePortTypeV1 - if type(instance) is ConsoleServerPort: - return ConsoleServerPortTypeV1 - if type(instance) is FrontPort: - return FrontPortTypeV1 - if type(instance) is Interface: - return InterfaceTypeV1 - if type(instance) is PowerFeed: - return PowerFeedTypeV1 - if type(instance) is PowerOutlet: - return PowerOutletTypeV1 - if type(instance) is PowerPort: - return PowerPortTypeV1 - if type(instance) is ProviderNetwork: - return ProviderNetworkTypeV1 - if type(instance) is RearPort: - return RearPortTypeV1 diff --git a/netbox/dcim/graphql/mixins_v1.py b/netbox/dcim/graphql/mixins_v1.py deleted file mode 100644 index 694283e3c..000000000 --- a/netbox/dcim/graphql/mixins_v1.py +++ /dev/null @@ -1,43 +0,0 @@ -from typing import Annotated, List, Union - -import strawberry - -__all__ = ( - 'CabledObjectMixinV1', - 'PathEndpointMixinV1', -) - - -@strawberry.type -class CabledObjectMixinV1: - cable: Annotated["CableTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None # noqa: F821 - - link_peers: List[Annotated[Union[ - Annotated["CircuitTerminationTypeV1", strawberry.lazy('circuits.graphql.types_v1')], # noqa: F821 - Annotated["ConsolePortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 - Annotated["ConsoleServerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 - Annotated["FrontPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 - Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 - Annotated["PowerFeedTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 - Annotated["PowerOutletTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 - Annotated["PowerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 - Annotated["RearPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 - ], strawberry.union("LinkPeerType")]] - - -@strawberry.type -class PathEndpointMixinV1: - - connected_endpoints: List[Annotated[Union[ - Annotated["CircuitTerminationTypeV1", strawberry.lazy('circuits.graphql.types_v1')], # noqa: F821 - Annotated["VirtualCircuitTerminationTypeV1", strawberry.lazy('circuits.graphql.types_v1')], # noqa: F821 - Annotated["ConsolePortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 - Annotated["ConsoleServerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 - Annotated["FrontPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 - Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 - Annotated["PowerFeedTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 - Annotated["PowerOutletTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 - Annotated["PowerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 - Annotated["ProviderNetworkTypeV1", strawberry.lazy('circuits.graphql.types_v1')], # noqa: F821 - Annotated["RearPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], # noqa: F821 - ], strawberry.union("ConnectedEndpointTypeV1")]] diff --git a/netbox/dcim/graphql/schema_v1.py b/netbox/dcim/graphql/schema_v1.py deleted file mode 100644 index 29d2f09ba..000000000 --- a/netbox/dcim/graphql/schema_v1.py +++ /dev/null @@ -1,138 +0,0 @@ -from typing import List - -import strawberry -import strawberry_django - -from .types_v1 import * - - -@strawberry.type(name="Query") -class DCIMQueryV1: - cable: CableTypeV1 = strawberry_django.field() - cable_list: List[CableTypeV1] = strawberry_django.field() - - console_port: ConsolePortTypeV1 = strawberry_django.field() - console_port_list: List[ConsolePortTypeV1] = strawberry_django.field() - - console_port_template: ConsolePortTemplateTypeV1 = strawberry_django.field() - console_port_template_list: List[ConsolePortTemplateTypeV1] = strawberry_django.field() - - console_server_port: ConsoleServerPortTypeV1 = strawberry_django.field() - console_server_port_list: List[ConsoleServerPortTypeV1] = strawberry_django.field() - - console_server_port_template: ConsoleServerPortTemplateTypeV1 = strawberry_django.field() - console_server_port_template_list: List[ConsoleServerPortTemplateTypeV1] = strawberry_django.field() - - device: DeviceTypeV1 = strawberry_django.field() - device_list: List[DeviceTypeV1] = strawberry_django.field() - - device_bay: DeviceBayTypeV1 = strawberry_django.field() - device_bay_list: List[DeviceBayTypeV1] = strawberry_django.field() - - device_bay_template: DeviceBayTemplateTypeV1 = strawberry_django.field() - device_bay_template_list: List[DeviceBayTemplateTypeV1] = strawberry_django.field() - - device_role: DeviceRoleTypeV1 = strawberry_django.field() - device_role_list: List[DeviceRoleTypeV1] = strawberry_django.field() - - device_type: DeviceTypeTypeV1 = strawberry_django.field() - device_type_list: List[DeviceTypeTypeV1] = strawberry_django.field() - - front_port: FrontPortTypeV1 = strawberry_django.field() - front_port_list: List[FrontPortTypeV1] = strawberry_django.field() - - front_port_template: FrontPortTemplateTypeV1 = strawberry_django.field() - front_port_template_list: List[FrontPortTemplateTypeV1] = strawberry_django.field() - - mac_address: MACAddressTypeV1 = strawberry_django.field() - mac_address_list: List[MACAddressTypeV1] = strawberry_django.field() - - interface: InterfaceTypeV1 = strawberry_django.field() - interface_list: List[InterfaceTypeV1] = strawberry_django.field() - - interface_template: InterfaceTemplateTypeV1 = strawberry_django.field() - interface_template_list: List[InterfaceTemplateTypeV1] = strawberry_django.field() - - inventory_item: InventoryItemTypeV1 = strawberry_django.field() - inventory_item_list: List[InventoryItemTypeV1] = strawberry_django.field() - - inventory_item_role: InventoryItemRoleTypeV1 = strawberry_django.field() - inventory_item_role_list: List[InventoryItemRoleTypeV1] = strawberry_django.field() - - inventory_item_template: InventoryItemTemplateTypeV1 = strawberry_django.field() - inventory_item_template_list: List[InventoryItemTemplateTypeV1] = strawberry_django.field() - - location: LocationTypeV1 = strawberry_django.field() - location_list: List[LocationTypeV1] = strawberry_django.field() - - manufacturer: ManufacturerTypeV1 = strawberry_django.field() - manufacturer_list: List[ManufacturerTypeV1] = strawberry_django.field() - - module: ModuleTypeV1 = strawberry_django.field() - module_list: List[ModuleTypeV1] = strawberry_django.field() - - module_bay: ModuleBayTypeV1 = strawberry_django.field() - module_bay_list: List[ModuleBayTypeV1] = strawberry_django.field() - - module_bay_template: ModuleBayTemplateTypeV1 = strawberry_django.field() - module_bay_template_list: List[ModuleBayTemplateTypeV1] = strawberry_django.field() - - module_type_profile: ModuleTypeProfileTypeV1 = strawberry_django.field() - module_type_profile_list: List[ModuleTypeProfileTypeV1] = strawberry_django.field() - - module_type: ModuleTypeTypeV1 = strawberry_django.field() - module_type_list: List[ModuleTypeTypeV1] = strawberry_django.field() - - platform: PlatformTypeV1 = strawberry_django.field() - platform_list: List[PlatformTypeV1] = strawberry_django.field() - - power_feed: PowerFeedTypeV1 = strawberry_django.field() - power_feed_list: List[PowerFeedTypeV1] = strawberry_django.field() - - power_outlet: PowerOutletTypeV1 = strawberry_django.field() - power_outlet_list: List[PowerOutletTypeV1] = strawberry_django.field() - - power_outlet_template: PowerOutletTemplateTypeV1 = strawberry_django.field() - power_outlet_template_list: List[PowerOutletTemplateTypeV1] = strawberry_django.field() - - power_panel: PowerPanelTypeV1 = strawberry_django.field() - power_panel_list: List[PowerPanelTypeV1] = strawberry_django.field() - - power_port: PowerPortTypeV1 = strawberry_django.field() - power_port_list: List[PowerPortTypeV1] = strawberry_django.field() - - power_port_template: PowerPortTemplateTypeV1 = strawberry_django.field() - power_port_template_list: List[PowerPortTemplateTypeV1] = strawberry_django.field() - - rack_type: RackTypeTypeV1 = strawberry_django.field() - rack_type_list: List[RackTypeTypeV1] = strawberry_django.field() - - rack: RackTypeV1 = strawberry_django.field() - rack_list: List[RackTypeV1] = strawberry_django.field() - - rack_reservation: RackReservationTypeV1 = strawberry_django.field() - rack_reservation_list: List[RackReservationTypeV1] = strawberry_django.field() - - rack_role: RackRoleTypeV1 = strawberry_django.field() - rack_role_list: List[RackRoleTypeV1] = strawberry_django.field() - - rear_port: RearPortTypeV1 = strawberry_django.field() - rear_port_list: List[RearPortTypeV1] = strawberry_django.field() - - rear_port_template: RearPortTemplateTypeV1 = strawberry_django.field() - rear_port_template_list: List[RearPortTemplateTypeV1] = strawberry_django.field() - - region: RegionTypeV1 = strawberry_django.field() - region_list: List[RegionTypeV1] = strawberry_django.field() - - site: SiteTypeV1 = strawberry_django.field() - site_list: List[SiteTypeV1] = strawberry_django.field() - - site_group: SiteGroupTypeV1 = strawberry_django.field() - site_group_list: List[SiteGroupTypeV1] = strawberry_django.field() - - virtual_chassis: VirtualChassisTypeV1 = strawberry_django.field() - virtual_chassis_list: List[VirtualChassisTypeV1] = strawberry_django.field() - - virtual_device_context: VirtualDeviceContextTypeV1 = strawberry_django.field() - virtual_device_context_list: List[VirtualDeviceContextTypeV1] = strawberry_django.field() diff --git a/netbox/dcim/graphql/types_v1.py b/netbox/dcim/graphql/types_v1.py deleted file mode 100644 index ab0ba5f07..000000000 --- a/netbox/dcim/graphql/types_v1.py +++ /dev/null @@ -1,903 +0,0 @@ -from typing import Annotated, List, TYPE_CHECKING, Union - -import strawberry -import strawberry_django - -from core.graphql.mixins_v1 import ChangelogMixinV1 -from dcim import models -from extras.graphql.mixins_v1 import ( - ConfigContextMixinV1, - ContactsMixinV1, - ImageAttachmentsMixinV1, -) -from ipam.graphql.mixins_v1 import IPAddressesMixinV1, VLANGroupsMixinV1 -from netbox.graphql.scalars import BigInt -from netbox.graphql.types_v1 import ( - BaseObjectTypeV1, NetBoxObjectTypeV1, OrganizationalObjectTypeV1, PrimaryObjectTypeV1 -) -from users.graphql.mixins_v1 import OwnerMixinV1 -from .filters_v1 import * -from .mixins_v1 import CabledObjectMixinV1, PathEndpointMixinV1 - -if TYPE_CHECKING: - from circuits.graphql.types_v1 import CircuitTerminationTypeV1 - from extras.graphql.types_v1 import ConfigTemplateTypeV1 - from ipam.graphql.types_v1 import ( - ASNTypeV1, - IPAddressTypeV1, - PrefixTypeV1, - ServiceTypeV1, - VLANTranslationPolicyTypeV1, - VLANTypeV1, - VRFTypeV1, - ) - from tenancy.graphql.types_v1 import TenantTypeV1 - from users.graphql.types_v1 import UserTypeV1 - from virtualization.graphql.types_v1 import ClusterTypeV1, VMInterfaceTypeV1, VirtualMachineTypeV1 - from vpn.graphql.types_v1 import L2VPNTerminationTypeV1 - from wireless.graphql.types_v1 import WirelessLANTypeV1, WirelessLinkTypeV1 - -__all__ = ( - 'CableTypeV1', - 'ComponentTypeV1', - 'ConsolePortTypeV1', - 'ConsolePortTemplateTypeV1', - 'ConsoleServerPortTypeV1', - 'ConsoleServerPortTemplateTypeV1', - 'DeviceTypeV1', - 'DeviceBayTypeV1', - 'DeviceBayTemplateTypeV1', - 'DeviceRoleTypeV1', - 'DeviceTypeTypeV1', - 'FrontPortTypeV1', - 'FrontPortTemplateTypeV1', - 'InterfaceTypeV1', - 'InterfaceTemplateTypeV1', - 'InventoryItemTypeV1', - 'InventoryItemRoleTypeV1', - 'InventoryItemTemplateTypeV1', - 'LocationTypeV1', - 'MACAddressTypeV1', - 'ManufacturerTypeV1', - 'ModularComponentTypeV1', - 'ModuleTypeV1', - 'ModuleBayTypeV1', - 'ModuleBayTemplateTypeV1', - 'ModuleTypeProfileTypeV1', - 'ModuleTypeTypeV1', - 'PlatformTypeV1', - 'PowerFeedTypeV1', - 'PowerOutletTypeV1', - 'PowerOutletTemplateTypeV1', - 'PowerPanelTypeV1', - 'PowerPortTypeV1', - 'PowerPortTemplateTypeV1', - 'RackTypeV1', - 'RackReservationTypeV1', - 'RackRoleTypeV1', - 'RackTypeTypeV1', - 'RearPortTypeV1', - 'RearPortTemplateTypeV1', - 'RegionTypeV1', - 'SiteTypeV1', - 'SiteGroupTypeV1', - 'VirtualChassisTypeV1', - 'VirtualDeviceContextTypeV1', -) - - -# -# Base types -# - - -@strawberry.type -class ComponentTypeV1(OwnerMixinV1, NetBoxObjectTypeV1): - """ - Base type for device/VM components - """ - device: Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] - - -@strawberry.type -class ModularComponentTypeV1(ComponentTypeV1): - module: Annotated["ModuleTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - - -@strawberry.type -class ComponentTemplateTypeV1( - ChangelogMixinV1, - BaseObjectTypeV1 -): - """ - Base type for device/VM components - """ - device_type: Annotated["DeviceTypeTypeV1", strawberry.lazy('dcim.graphql.types_v1')] - - -@strawberry.type -class ModularComponentTemplateTypeV1(ComponentTemplateTypeV1): - """ - Base type for ComponentTemplateModel which supports optional assignment to a ModuleType. - """ - device_type: Annotated["DeviceTypeTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - module_type: Annotated["ModuleTypeTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - -# -# Model types -# - - -@strawberry_django.type( - models.CableTermination, - exclude=['termination_type', 'termination_id', '_device', '_rack', '_location', '_site'], - filters=CableTerminationFilterV1, - pagination=True -) -class CableTerminationTypeV1(NetBoxObjectTypeV1): - cable: Annotated["CableTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - termination: Annotated[Union[ - Annotated["CircuitTerminationTypeV1", strawberry.lazy('circuits.graphql.types_v1')], - Annotated["ConsolePortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["ConsoleServerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["FrontPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["PowerFeedTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["PowerOutletTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["PowerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["RearPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - ], strawberry.union("CableTerminationTerminationTypeV1")] | None - - -@strawberry_django.type( - models.Cable, - fields='__all__', - filters=CableFilterV1, - pagination=True -) -class CableTypeV1(PrimaryObjectTypeV1): - color: str - tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None - - terminations: List[CableTerminationTypeV1] - - a_terminations: List[Annotated[Union[ - Annotated["CircuitTerminationTypeV1", strawberry.lazy('circuits.graphql.types_v1')], - Annotated["ConsolePortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["ConsoleServerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["FrontPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["PowerFeedTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["PowerOutletTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["PowerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["RearPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - ], strawberry.union("CableTerminationTerminationTypeV1")]] - - b_terminations: List[Annotated[Union[ - Annotated["CircuitTerminationTypeV1", strawberry.lazy('circuits.graphql.types_v1')], - Annotated["ConsolePortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["ConsoleServerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["FrontPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["PowerFeedTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["PowerOutletTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["PowerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["RearPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - ], strawberry.union("CableTerminationTerminationTypeV1")]] - - -@strawberry_django.type( - models.ConsolePort, - exclude=['_path'], - filters=ConsolePortFilterV1, - pagination=True -) -class ConsolePortTypeV1(ModularComponentTypeV1, CabledObjectMixinV1, PathEndpointMixinV1): - pass - - -@strawberry_django.type( - models.ConsolePortTemplate, - fields='__all__', - filters=ConsolePortTemplateFilterV1, - pagination=True -) -class ConsolePortTemplateTypeV1(ModularComponentTemplateTypeV1): - pass - - -@strawberry_django.type( - models.ConsoleServerPort, - exclude=['_path'], - filters=ConsoleServerPortFilterV1, - pagination=True -) -class ConsoleServerPortTypeV1(ModularComponentTypeV1, CabledObjectMixinV1, PathEndpointMixinV1): - pass - - -@strawberry_django.type( - models.ConsoleServerPortTemplate, - fields='__all__', - filters=ConsoleServerPortTemplateFilterV1, - pagination=True -) -class ConsoleServerPortTemplateTypeV1(ModularComponentTemplateTypeV1): - pass - - -@strawberry_django.type( - models.Device, - fields='__all__', - filters=DeviceFilterV1, - pagination=True -) -class DeviceTypeV1(ConfigContextMixinV1, ImageAttachmentsMixinV1, ContactsMixinV1, PrimaryObjectTypeV1): - console_port_count: BigInt - console_server_port_count: BigInt - power_port_count: BigInt - power_outlet_count: BigInt - interface_count: BigInt - front_port_count: BigInt - rear_port_count: BigInt - device_bay_count: BigInt - module_bay_count: BigInt - inventory_item_count: BigInt - config_template: Annotated["ConfigTemplateTypeV1", strawberry.lazy('extras.graphql.types_v1')] | None - device_type: Annotated["DeviceTypeTypeV1", strawberry.lazy('dcim.graphql.types_v1')] - role: Annotated["DeviceRoleTypeV1", strawberry.lazy('dcim.graphql.types_v1')] - tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None - platform: Annotated["PlatformTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - site: Annotated["SiteTypeV1", strawberry.lazy('dcim.graphql.types_v1')] - location: Annotated["LocationTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - rack: Annotated["RackTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - primary_ip4: Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - primary_ip6: Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - oob_ip: Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - cluster: Annotated["ClusterTypeV1", strawberry.lazy('virtualization.graphql.types_v1')] | None - virtual_chassis: Annotated["VirtualChassisTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - - virtual_machines: List[Annotated["VirtualMachineTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] - modules: List[Annotated["ModuleTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - interfaces: List[Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - rearports: List[Annotated["RearPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - consoleports: List[Annotated["ConsolePortTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - powerports: List[Annotated["PowerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - cabletermination_set: List[Annotated["CableTerminationTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - consoleserverports: List[Annotated["ConsoleServerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - poweroutlets: List[Annotated["PowerOutletTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - frontports: List[Annotated["FrontPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - devicebays: List[Annotated["DeviceBayTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - modulebays: List[Annotated["ModuleBayTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - services: List[Annotated["ServiceTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] - inventoryitems: List[Annotated["InventoryItemTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - vdcs: List[Annotated["VirtualDeviceContextTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - @strawberry_django.field - def vc_master_for(self) -> Annotated["VirtualChassisTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None: - return self.vc_master_for if hasattr(self, 'vc_master_for') else None - - @strawberry_django.field - def parent_bay(self) -> Annotated["DeviceBayTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None: - return self.parent_bay if hasattr(self, 'parent_bay') else None - - -@strawberry_django.type( - models.DeviceBay, - fields='__all__', - filters=DeviceBayFilterV1, - pagination=True -) -class DeviceBayTypeV1(ComponentTypeV1): - installed_device: Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - - -@strawberry_django.type( - models.DeviceBayTemplate, - fields='__all__', - filters=DeviceBayTemplateFilterV1, - pagination=True -) -class DeviceBayTemplateTypeV1(ComponentTemplateTypeV1): - pass - - -@strawberry_django.type( - models.InventoryItemTemplate, - exclude=['component_type', 'component_id', 'parent'], - filters=InventoryItemTemplateFilterV1, - pagination=True -) -class InventoryItemTemplateTypeV1(ComponentTemplateTypeV1): - role: Annotated["InventoryItemRoleTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - manufacturer: Annotated["ManufacturerTypeV1", strawberry.lazy('dcim.graphql.types_v1')] - - @strawberry_django.field - def parent(self) -> Annotated["InventoryItemTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None: - return self.parent - - child_items: List[Annotated["InventoryItemTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - component: Annotated[Union[ - Annotated["ConsolePortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["ConsoleServerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["FrontPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["PowerOutletTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["PowerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["RearPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - ], strawberry.union("InventoryItemTemplateComponentTypeV1")] | None - - -@strawberry_django.type( - models.DeviceRole, - fields='__all__', - filters=DeviceRoleFilterV1, - pagination=True -) -class DeviceRoleTypeV1(OrganizationalObjectTypeV1): - parent: Annotated['DeviceRoleTypeV1', strawberry.lazy('dcim.graphql.types_v1')] | None - children: List[Annotated['DeviceRoleTypeV1', strawberry.lazy('dcim.graphql.types_v1')]] - color: str - config_template: Annotated["ConfigTemplateTypeV1", strawberry.lazy('extras.graphql.types_v1')] | None - - virtual_machines: List[Annotated["VirtualMachineTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] - devices: List[Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - -@strawberry_django.type( - models.DeviceType, - fields='__all__', - filters=DeviceTypeFilterV1, - pagination=True -) -class DeviceTypeTypeV1(PrimaryObjectTypeV1): - console_port_template_count: BigInt - console_server_port_template_count: BigInt - power_port_template_count: BigInt - power_outlet_template_count: BigInt - interface_template_count: BigInt - front_port_template_count: BigInt - rear_port_template_count: BigInt - device_bay_template_count: BigInt - module_bay_template_count: BigInt - inventory_item_template_count: BigInt - front_image: strawberry_django.fields.types.DjangoImageType | None - rear_image: strawberry_django.fields.types.DjangoImageType | None - manufacturer: Annotated["ManufacturerTypeV1", strawberry.lazy('dcim.graphql.types_v1')] - default_platform: Annotated["PlatformTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - - frontporttemplates: List[Annotated["FrontPortTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - modulebaytemplates: List[Annotated["ModuleBayTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - instances: List[Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - poweroutlettemplates: List[Annotated["PowerOutletTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - powerporttemplates: List[Annotated["PowerPortTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - inventoryitemtemplates: List[Annotated["InventoryItemTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - rearporttemplates: List[Annotated["RearPortTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - consoleserverporttemplates: List[ - Annotated["ConsoleServerPortTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')] - ] - interfacetemplates: List[Annotated["InterfaceTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - devicebaytemplates: List[Annotated["DeviceBayTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - consoleporttemplates: List[Annotated["ConsolePortTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - -@strawberry_django.type( - models.FrontPort, - fields='__all__', - filters=FrontPortFilterV1, - pagination=True -) -class FrontPortTypeV1(ModularComponentTypeV1, CabledObjectMixinV1): - color: str - rear_port: Annotated["RearPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')] - - -@strawberry_django.type( - models.FrontPortTemplate, - fields='__all__', - filters=FrontPortTemplateFilterV1, - pagination=True -) -class FrontPortTemplateTypeV1(ModularComponentTemplateTypeV1): - color: str - rear_port: Annotated["RearPortTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')] - - -@strawberry_django.type( - models.MACAddress, - exclude=['assigned_object_type', 'assigned_object_id'], - filters=MACAddressFilterV1, - pagination=True -) -class MACAddressTypeV1(PrimaryObjectTypeV1): - mac_address: str - - @strawberry_django.field - def assigned_object(self) -> Annotated[Union[ - Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["VMInterfaceTypeV1", strawberry.lazy('virtualization.graphql.types_v1')], - ], strawberry.union("MACAddressAssignmentTypeV1")] | None: - return self.assigned_object - - -@strawberry_django.type( - models.Interface, - exclude=['_path'], - filters=InterfaceFilterV1, - pagination=True -) -class InterfaceTypeV1(IPAddressesMixinV1, ModularComponentTypeV1, CabledObjectMixinV1, PathEndpointMixinV1): - _name: str - wwn: str | None - parent: Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - bridge: Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - lag: Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - wireless_link: Annotated["WirelessLinkTypeV1", strawberry.lazy('wireless.graphql.types_v1')] | None - untagged_vlan: Annotated["VLANTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - vrf: Annotated["VRFTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - primary_mac_address: Annotated["MACAddressTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - qinq_svlan: Annotated["VLANTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - vlan_translation_policy: Annotated["VLANTranslationPolicyTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - l2vpn_termination: Annotated["L2VPNTerminationTypeV1", strawberry.lazy('vpn.graphql.types_v1')] | None - - vdcs: List[Annotated["VirtualDeviceContextTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - tagged_vlans: List[Annotated["VLANTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] - bridge_interfaces: List[Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - wireless_lans: List[Annotated["WirelessLANTypeV1", strawberry.lazy('wireless.graphql.types_v1')]] - member_interfaces: List[Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - child_interfaces: List[Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - mac_addresses: List[Annotated["MACAddressTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - -@strawberry_django.type( - models.InterfaceTemplate, - fields='__all__', - filters=InterfaceTemplateFilterV1, - pagination=True -) -class InterfaceTemplateTypeV1(ModularComponentTemplateTypeV1): - _name: str - bridge: Annotated["InterfaceTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - - bridge_interfaces: List[Annotated["InterfaceTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - -@strawberry_django.type( - models.InventoryItem, - exclude=['component_type', 'component_id', 'parent'], - filters=InventoryItemFilterV1, - pagination=True -) -class InventoryItemTypeV1(ComponentTypeV1): - role: Annotated["InventoryItemRoleTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - manufacturer: Annotated["ManufacturerTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - - child_items: List[Annotated["InventoryItemTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - @strawberry_django.field - def parent(self) -> Annotated["InventoryItemTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None: - return self.parent - - component: Annotated[Union[ - Annotated["ConsolePortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["ConsoleServerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["FrontPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["PowerOutletTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["PowerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["RearPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - ], strawberry.union("InventoryItemComponentTypeV1")] | None - - -@strawberry_django.type( - models.InventoryItemRole, - fields='__all__', - filters=InventoryItemRoleFilterV1, - pagination=True -) -class InventoryItemRoleTypeV1(OrganizationalObjectTypeV1): - color: str - - inventory_items: List[Annotated["InventoryItemTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - inventory_item_templates: List[Annotated["InventoryItemTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - -@strawberry_django.type( - models.Location, - # fields='__all__', - exclude=['parent'], # bug - temp - filters=LocationFilterV1, - pagination=True -) -class LocationTypeV1(VLANGroupsMixinV1, ImageAttachmentsMixinV1, ContactsMixinV1, OrganizationalObjectTypeV1): - site: Annotated["SiteTypeV1", strawberry.lazy('dcim.graphql.types_v1')] - tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None - parent: Annotated["LocationTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - - powerpanel_set: List[Annotated["PowerPanelTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - cabletermination_set: List[Annotated["CableTerminationTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - racks: List[Annotated["RackTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - devices: List[Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - children: List[Annotated["LocationTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - @strawberry_django.field - def clusters(self) -> List[Annotated["ClusterTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]]: - return self.cluster_set.all() - - @strawberry_django.field - def circuit_terminations(self) -> List[ - Annotated["CircuitTerminationTypeV1", strawberry.lazy('circuits.graphql.types_v1')] - ]: - return self.circuit_terminations.all() - - -@strawberry_django.type( - models.Manufacturer, - fields='__all__', - filters=ManufacturerFilterV1, - pagination=True -) -class ManufacturerTypeV1(OrganizationalObjectTypeV1, ContactsMixinV1): - - platforms: List[Annotated["PlatformTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - device_types: List[Annotated["DeviceTypeTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - inventory_item_templates: List[Annotated["InventoryItemTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - inventory_items: List[Annotated["InventoryItemTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - module_types: List[Annotated["ModuleTypeTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - -@strawberry_django.type( - models.Module, - fields='__all__', - filters=ModuleFilterV1, - pagination=True -) -class ModuleTypeV1(PrimaryObjectTypeV1): - device: Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] - module_bay: Annotated["ModuleBayTypeV1", strawberry.lazy('dcim.graphql.types_v1')] - module_type: Annotated["ModuleTypeTypeV1", strawberry.lazy('dcim.graphql.types_v1')] - - interfaces: List[Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - powerports: List[Annotated["PowerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - consoleserverports: List[Annotated["ConsoleServerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - consoleports: List[Annotated["ConsolePortTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - poweroutlets: List[Annotated["PowerOutletTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - rearports: List[Annotated["RearPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - frontports: List[Annotated["FrontPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - -@strawberry_django.type( - models.ModuleBay, - # fields='__all__', - exclude=['parent'], - filters=ModuleBayFilterV1, - pagination=True -) -class ModuleBayTypeV1(ModularComponentTypeV1): - - installed_module: Annotated["ModuleTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - children: List[Annotated["ModuleBayTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - @strawberry_django.field - def parent(self) -> Annotated["ModuleBayTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None: - return self.parent - - -@strawberry_django.type( - models.ModuleBayTemplate, - fields='__all__', - filters=ModuleBayTemplateFilterV1, - pagination=True -) -class ModuleBayTemplateTypeV1(ModularComponentTemplateTypeV1): - pass - - -@strawberry_django.type( - models.ModuleTypeProfile, - fields='__all__', - filters=ModuleTypeProfileFilterV1, - pagination=True -) -class ModuleTypeProfileTypeV1(PrimaryObjectTypeV1): - module_types: List[Annotated["ModuleTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - -@strawberry_django.type( - models.ModuleType, - fields='__all__', - filters=ModuleTypeFilterV1, - pagination=True -) -class ModuleTypeTypeV1(PrimaryObjectTypeV1): - profile: Annotated["ModuleTypeProfileTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - manufacturer: Annotated["ManufacturerTypeV1", strawberry.lazy('dcim.graphql.types_v1')] - - frontporttemplates: List[Annotated["FrontPortTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - consoleserverporttemplates: List[ - Annotated["ConsoleServerPortTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')] - ] - interfacetemplates: List[Annotated["InterfaceTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - powerporttemplates: List[Annotated["PowerPortTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - poweroutlettemplates: List[Annotated["PowerOutletTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - rearporttemplates: List[Annotated["RearPortTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - instances: List[Annotated["ModuleTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - consoleporttemplates: List[Annotated["ConsolePortTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - -@strawberry_django.type( - models.Platform, - fields='__all__', - filters=PlatformFilterV1, - pagination=True -) -class PlatformTypeV1(OrganizationalObjectTypeV1): - parent: Annotated['PlatformTypeV1', strawberry.lazy('dcim.graphql.types_v1')] | None - children: List[Annotated['PlatformTypeV1', strawberry.lazy('dcim.graphql.types_v1')]] - manufacturer: Annotated["ManufacturerTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - config_template: Annotated["ConfigTemplateTypeV1", strawberry.lazy('extras.graphql.types_v1')] | None - - virtual_machines: List[Annotated["VirtualMachineTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] - devices: List[Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - -@strawberry_django.type( - models.PowerFeed, - exclude=['_path'], - filters=PowerFeedFilterV1, - pagination=True -) -class PowerFeedTypeV1(CabledObjectMixinV1, PathEndpointMixinV1, PrimaryObjectTypeV1): - power_panel: Annotated["PowerPanelTypeV1", strawberry.lazy('dcim.graphql.types_v1')] - rack: Annotated["RackTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None - - -@strawberry_django.type( - models.PowerOutlet, - exclude=['_path'], - filters=PowerOutletFilterV1, - pagination=True -) -class PowerOutletTypeV1(ModularComponentTypeV1, CabledObjectMixinV1, PathEndpointMixinV1): - power_port: Annotated["PowerPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - color: str - - -@strawberry_django.type( - models.PowerOutletTemplate, - fields='__all__', - filters=PowerOutletTemplateFilterV1, - pagination=True -) -class PowerOutletTemplateTypeV1(ModularComponentTemplateTypeV1): - power_port: Annotated["PowerPortTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - color: str - - -@strawberry_django.type( - models.PowerPanel, - fields='__all__', - filters=PowerPanelFilterV1, - pagination=True -) -class PowerPanelTypeV1(ContactsMixinV1, PrimaryObjectTypeV1): - site: Annotated["SiteTypeV1", strawberry.lazy('dcim.graphql.types_v1')] - location: Annotated["LocationTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - - powerfeeds: List[Annotated["PowerFeedTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - -@strawberry_django.type( - models.PowerPort, - exclude=['_path'], - filters=PowerPortFilterV1, - pagination=True -) -class PowerPortTypeV1(ModularComponentTypeV1, CabledObjectMixinV1, PathEndpointMixinV1): - - poweroutlets: List[Annotated["PowerOutletTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - -@strawberry_django.type( - models.PowerPortTemplate, - fields='__all__', - filters=PowerPortTemplateFilterV1, - pagination=True -) -class PowerPortTemplateTypeV1(ModularComponentTemplateTypeV1): - poweroutlet_templates: List[Annotated["PowerOutletTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - -@strawberry_django.type( - models.RackType, - fields='__all__', - filters=RackTypeFilterV1, - pagination=True -) -class RackTypeTypeV1(PrimaryObjectTypeV1): - manufacturer: Annotated["ManufacturerTypeV1", strawberry.lazy('dcim.graphql.types_v1')] - - -@strawberry_django.type( - models.Rack, - fields='__all__', - filters=RackFilterV1, - pagination=True -) -class RackTypeV1(VLANGroupsMixinV1, ImageAttachmentsMixinV1, ContactsMixinV1, PrimaryObjectTypeV1): - site: Annotated["SiteTypeV1", strawberry.lazy('dcim.graphql.types_v1')] - location: Annotated["LocationTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None - role: Annotated["RackRoleTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - - rack_type: Annotated["RackTypeTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - reservations: List[Annotated["RackReservationTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - devices: List[Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - powerfeeds: List[Annotated["PowerFeedTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - cabletermination_set: List[Annotated["CableTerminationTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - -@strawberry_django.type( - models.RackReservation, - fields='__all__', - filters=RackReservationFilterV1, - pagination=True -) -class RackReservationTypeV1(PrimaryObjectTypeV1): - units: List[int] - rack: Annotated["RackTypeV1", strawberry.lazy('dcim.graphql.types_v1')] - tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None - user: Annotated["UserTypeV1", strawberry.lazy('users.graphql.types_v1')] - - -@strawberry_django.type( - models.RackRole, - fields='__all__', - filters=RackRoleFilterV1, - pagination=True -) -class RackRoleTypeV1(OrganizationalObjectTypeV1): - color: str - - racks: List[Annotated["RackTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - -@strawberry_django.type( - models.RearPort, - fields='__all__', - filters=RearPortFilterV1, - pagination=True -) -class RearPortTypeV1(ModularComponentTypeV1, CabledObjectMixinV1): - color: str - - frontports: List[Annotated["FrontPortTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - -@strawberry_django.type( - models.RearPortTemplate, - fields='__all__', - filters=RearPortTemplateFilterV1, - pagination=True -) -class RearPortTemplateTypeV1(ModularComponentTemplateTypeV1): - color: str - - frontport_templates: List[Annotated["FrontPortTemplateTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - -@strawberry_django.type( - models.Region, - exclude=['parent'], - filters=RegionFilterV1, - pagination=True -) -class RegionTypeV1(VLANGroupsMixinV1, ContactsMixinV1, OrganizationalObjectTypeV1): - - sites: List[Annotated["SiteTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - children: List[Annotated["RegionTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - @strawberry_django.field - def parent(self) -> Annotated["RegionTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None: - return self.parent - - @strawberry_django.field - def clusters(self) -> List[Annotated["ClusterTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]]: - return self.cluster_set.all() - - @strawberry_django.field - def circuit_terminations(self) -> List[ - Annotated["CircuitTerminationTypeV1", strawberry.lazy('circuits.graphql.types_v1')] - ]: - return self.circuit_terminations.all() - - -@strawberry_django.type( - models.Site, - fields='__all__', - filters=SiteFilterV1, - pagination=True -) -class SiteTypeV1(VLANGroupsMixinV1, ImageAttachmentsMixinV1, ContactsMixinV1, PrimaryObjectTypeV1): - time_zone: str | None - region: Annotated["RegionTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - group: Annotated["SiteGroupTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None - - prefixes: List[Annotated["PrefixTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] - virtual_machines: List[Annotated["VirtualMachineTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] - racks: List[Annotated["RackTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - cabletermination_set: List[Annotated["CableTerminationTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - powerpanel_set: List[Annotated["PowerPanelTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - devices: List[Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - locations: List[Annotated["LocationTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - asns: List[Annotated["ASNTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] - circuit_terminations: List[Annotated["CircuitTerminationTypeV1", strawberry.lazy('circuits.graphql.types_v1')]] - clusters: List[Annotated["ClusterTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] - vlans: List[Annotated["VLANTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] - - @strawberry_django.field - def clusters(self) -> List[Annotated["ClusterTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]]: - return self.cluster_set.all() - - @strawberry_django.field - def circuit_terminations(self) -> List[ - Annotated["CircuitTerminationTypeV1", strawberry.lazy('circuits.graphql.types_v1')] - ]: - return self.circuit_terminations.all() - - -@strawberry_django.type( - models.SiteGroup, - exclude=['parent'], # bug - temp - filters=SiteGroupFilterV1, - pagination=True -) -class SiteGroupTypeV1(VLANGroupsMixinV1, ContactsMixinV1, OrganizationalObjectTypeV1): - - sites: List[Annotated["SiteTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - children: List[Annotated["SiteGroupTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - @strawberry_django.field - def parent(self) -> Annotated["SiteGroupTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None: - return self.parent - - @strawberry_django.field - def clusters(self) -> List[Annotated["ClusterTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]]: - return self.cluster_set.all() - - @strawberry_django.field - def circuit_terminations(self) -> List[ - Annotated["CircuitTerminationTypeV1", strawberry.lazy('circuits.graphql.types_v1')] - ]: - return self.circuit_terminations.all() - - -@strawberry_django.type( - models.VirtualChassis, - fields='__all__', - filters=VirtualChassisFilterV1, - pagination=True -) -class VirtualChassisTypeV1(PrimaryObjectTypeV1): - member_count: BigInt - master: Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - - members: List[Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - -@strawberry_django.type( - models.VirtualDeviceContext, - fields='__all__', - filters=VirtualDeviceContextFilterV1, - pagination=True -) -class VirtualDeviceContextTypeV1(PrimaryObjectTypeV1): - device: Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - primary_ip4: Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - primary_ip6: Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None - - interfaces: List[Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] diff --git a/netbox/extras/graphql/filter_mixins_v1.py b/netbox/extras/graphql/filter_mixins_v1.py deleted file mode 100644 index 48611cc83..000000000 --- a/netbox/extras/graphql/filter_mixins_v1.py +++ /dev/null @@ -1,52 +0,0 @@ -from dataclasses import dataclass -from typing import Annotated, TYPE_CHECKING - -import strawberry -import strawberry_django -from strawberry_django import FilterLookup - -from core.graphql.filter_mixins_v1 import BaseFilterMixinV1 - -if TYPE_CHECKING: - from netbox.graphql.filter_lookups import JSONFilter - from .filters_v1 import * - -__all__ = ( - 'CustomFieldsFilterMixinV1', - 'JournalEntriesFilterMixinV1', - 'TagsFilterMixinV1', - 'ConfigContextFilterMixinV1', - 'TagBaseFilterMixinV1', -) - - -@dataclass -class CustomFieldsFilterMixinV1(BaseFilterMixinV1): - custom_field_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - - -@dataclass -class JournalEntriesFilterMixinV1(BaseFilterMixinV1): - journal_entries: Annotated['JournalEntryFilterV1', strawberry.lazy('extras.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@dataclass -class TagsFilterMixinV1(BaseFilterMixinV1): - tags: Annotated['TagFilter', strawberry.lazy('extras.graphql.filters')] | None = strawberry_django.filter_field() - - -@dataclass -class ConfigContextFilterMixinV1(BaseFilterMixinV1): - local_context_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - - -@dataclass -class TagBaseFilterMixinV1(BaseFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - slug: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/extras/graphql/filters_v1.py b/netbox/extras/graphql/filters_v1.py deleted file mode 100644 index b903fee34..000000000 --- a/netbox/extras/graphql/filters_v1.py +++ /dev/null @@ -1,357 +0,0 @@ -from typing import Annotated, TYPE_CHECKING - -import strawberry -import strawberry_django -from strawberry.scalars import ID -from strawberry_django import FilterLookup - -from core.graphql.filter_mixins_v1 import BaseObjectTypeFilterMixinV1, ChangeLogFilterMixinV1 -from extras import models -from extras.graphql.filter_mixins_v1 import TagBaseFilterMixinV1, CustomFieldsFilterMixinV1, TagsFilterMixinV1 -from netbox.graphql.filter_mixins_v1 import PrimaryModelFilterMixinV1, SyncedDataFilterMixinV1 - -if TYPE_CHECKING: - from core.graphql.filters_v1 import ContentTypeFilterV1 - from dcim.graphql.filters_v1 import ( - DeviceRoleFilterV1, - DeviceTypeFilterV1, - LocationFilterV1, - PlatformFilterV1, - RegionFilterV1, - SiteFilterV1, - SiteGroupFilterV1, - ) - from tenancy.graphql.filters_v1 import TenantFilterV1, TenantGroupFilterV1 - from netbox.graphql.enums import ColorEnum - from netbox.graphql.filter_lookups import FloatLookup, IntegerLookup, JSONFilter, StringArrayLookup, TreeNodeFilter - from virtualization.graphql.filters_v1 import ClusterFilterV1, ClusterGroupFilterV1, ClusterTypeFilterV1 - from .enums import * - -__all__ = ( - 'ConfigContextFilterV1', - 'ConfigContextProfileFilterV1', - 'ConfigTemplateFilterV1', - 'CustomFieldFilterV1', - 'CustomFieldChoiceSetFilterV1', - 'CustomLinkFilterV1', - 'EventRuleFilterV1', - 'ExportTemplateFilterV1', - 'ImageAttachmentFilterV1', - 'JournalEntryFilterV1', - 'NotificationGroupFilterV1', - 'SavedFilterFilterV1', - 'TableConfigFilterV1', - 'TagFilterV1', - 'WebhookFilterV1', -) - - -@strawberry_django.filter_type(models.ConfigContext, lookups=True) -class ConfigContextFilterV1(BaseObjectTypeFilterMixinV1, SyncedDataFilterMixinV1, ChangeLogFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - description: FilterLookup[str] | None = strawberry_django.filter_field() - is_active: FilterLookup[bool] | None = strawberry_django.filter_field() - regions: Annotated['RegionFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - region_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - site_groups: Annotated['SiteGroupFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - site_group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - sites: Annotated['SiteFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - locations: Annotated['LocationFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - device_types: Annotated['DeviceTypeFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - roles: Annotated['DeviceRoleFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - platforms: Annotated['PlatformFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - cluster_types: Annotated['ClusterTypeFilterV1', strawberry.lazy('virtualization.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - cluster_groups: Annotated['ClusterGroupFilterV1', strawberry.lazy('virtualization.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - clusters: Annotated['ClusterFilterV1', strawberry.lazy('virtualization.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - tenant_groups: Annotated['TenantGroupFilterV1', strawberry.lazy('tenancy.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - tenant_group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - tenants: Annotated['TenantFilterV1', strawberry.lazy('tenancy.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - tags: Annotated['TagFilterV1', strawberry.lazy('extras.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.ConfigContextProfile, lookups=True) -class ConfigContextProfileFilterV1(SyncedDataFilterMixinV1, PrimaryModelFilterMixinV1): - name: FilterLookup[str] = strawberry_django.filter_field() - description: FilterLookup[str] = strawberry_django.filter_field() - tags: Annotated['TagFilterV1', strawberry.lazy('extras.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.ConfigTemplate, lookups=True) -class ConfigTemplateFilterV1(BaseObjectTypeFilterMixinV1, SyncedDataFilterMixinV1, ChangeLogFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - description: FilterLookup[str] | None = strawberry_django.filter_field() - template_code: FilterLookup[str] | None = strawberry_django.filter_field() - environment_params: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - mime_type: FilterLookup[str] | None = strawberry_django.filter_field() - file_name: FilterLookup[str] | None = strawberry_django.filter_field() - file_extension: FilterLookup[str] | None = strawberry_django.filter_field() - as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.CustomField, lookups=True) -class CustomFieldFilterV1(BaseObjectTypeFilterMixinV1, ChangeLogFilterMixinV1): - type: Annotated['CustomFieldTypeEnum', strawberry.lazy('extras.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - object_types: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - related_object_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - name: FilterLookup[str] | None = strawberry_django.filter_field() - label: FilterLookup[str] | None = strawberry_django.filter_field() - group_name: FilterLookup[str] | None = strawberry_django.filter_field() - description: FilterLookup[str] | None = strawberry_django.filter_field() - required: FilterLookup[bool] | None = strawberry_django.filter_field() - unique: FilterLookup[bool] | None = strawberry_django.filter_field() - search_weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - filter_logic: Annotated['CustomFieldFilterLogicEnum', strawberry.lazy('extras.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - default: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - related_object_filter: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - validation_minimum: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - validation_maximum: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - validation_regex: FilterLookup[str] | None = strawberry_django.filter_field() - choice_set: Annotated['CustomFieldChoiceSetFilterV1', strawberry.lazy('extras.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - choice_set_id: ID | None = strawberry_django.filter_field() - ui_visible: Annotated['CustomFieldUIVisibleEnum', strawberry.lazy('extras.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - ui_editable: Annotated['CustomFieldUIEditableEnum', strawberry.lazy('extras.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - is_cloneable: FilterLookup[bool] | None = strawberry_django.filter_field() - comments: FilterLookup[str] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.CustomFieldChoiceSet, lookups=True) -class CustomFieldChoiceSetFilterV1(BaseObjectTypeFilterMixinV1, ChangeLogFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - description: FilterLookup[str] | None = strawberry_django.filter_field() - base_choices: Annotated['CustomFieldChoiceSetBaseEnum', strawberry.lazy('extras.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - extra_choices: Annotated['StringArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - order_alphabetically: FilterLookup[bool] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.CustomLink, lookups=True) -class CustomLinkFilterV1(BaseObjectTypeFilterMixinV1, ChangeLogFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - enabled: FilterLookup[bool] | None = strawberry_django.filter_field() - link_text: FilterLookup[str] | None = strawberry_django.filter_field() - link_url: FilterLookup[str] | None = strawberry_django.filter_field() - weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - group_name: FilterLookup[str] | None = strawberry_django.filter_field() - button_class: Annotated['CustomLinkButtonClassEnum', strawberry.lazy('extras.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - new_window: FilterLookup[bool] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.ExportTemplate, lookups=True) -class ExportTemplateFilterV1(BaseObjectTypeFilterMixinV1, SyncedDataFilterMixinV1, ChangeLogFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - description: FilterLookup[str] | None = strawberry_django.filter_field() - template_code: FilterLookup[str] | None = strawberry_django.filter_field() - environment_params: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - mime_type: FilterLookup[str] | None = strawberry_django.filter_field() - file_name: FilterLookup[str] | None = strawberry_django.filter_field() - file_extension: FilterLookup[str] | None = strawberry_django.filter_field() - as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.ImageAttachment, lookups=True) -class ImageAttachmentFilterV1(BaseObjectTypeFilterMixinV1, ChangeLogFilterMixinV1): - object_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - object_id: ID | None = strawberry_django.filter_field() - image_height: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - image_width: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - name: FilterLookup[str] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.JournalEntry, lookups=True) -class JournalEntryFilterV1( - BaseObjectTypeFilterMixinV1, CustomFieldsFilterMixinV1, TagsFilterMixinV1, ChangeLogFilterMixinV1 -): - assigned_object_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - assigned_object_type_id: ID | None = strawberry_django.filter_field() - assigned_object_id: ID | None = strawberry_django.filter_field() - created_by: Annotated['UserFilterV1', strawberry.lazy('users.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - kind: Annotated['JournalEntryKindEnum', strawberry.lazy('extras.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - comments: FilterLookup[str] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.NotificationGroup, lookups=True) -class NotificationGroupFilterV1(BaseObjectTypeFilterMixinV1, ChangeLogFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - description: FilterLookup[str] | None = strawberry_django.filter_field() - groups: Annotated['GroupFilterV1', strawberry.lazy('users.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - users: Annotated['UserFilterV1', strawberry.lazy('users.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.SavedFilter, lookups=True) -class SavedFilterFilterV1(BaseObjectTypeFilterMixinV1, ChangeLogFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - slug: FilterLookup[str] | None = strawberry_django.filter_field() - description: FilterLookup[str] | None = strawberry_django.filter_field() - user: Annotated['UserFilterV1', strawberry.lazy('users.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - user_id: ID | None = strawberry_django.filter_field() - weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - enabled: FilterLookup[bool] | None = strawberry_django.filter_field() - shared: FilterLookup[bool] | None = strawberry_django.filter_field() - parameters: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.TableConfig, lookups=True) -class TableConfigFilterV1(BaseObjectTypeFilterMixinV1, ChangeLogFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - description: FilterLookup[str] | None = strawberry_django.filter_field() - user: Annotated['UserFilterV1', strawberry.lazy('users.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - user_id: ID | None = strawberry_django.filter_field() - weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - enabled: FilterLookup[bool] | None = strawberry_django.filter_field() - shared: FilterLookup[bool] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.Tag, lookups=True) -class TagFilterV1(BaseObjectTypeFilterMixinV1, ChangeLogFilterMixinV1, TagBaseFilterMixinV1): - color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() - description: FilterLookup[str] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.Webhook, lookups=True) -class WebhookFilterV1( - BaseObjectTypeFilterMixinV1, CustomFieldsFilterMixinV1, TagsFilterMixinV1, ChangeLogFilterMixinV1 -): - name: FilterLookup[str] | None = strawberry_django.filter_field() - description: FilterLookup[str] | None = strawberry_django.filter_field() - payload_url: FilterLookup[str] | None = strawberry_django.filter_field() - http_method: Annotated['WebhookHttpMethodEnum', strawberry.lazy('extras.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - http_content_type: FilterLookup[str] | None = strawberry_django.filter_field() - additional_headers: FilterLookup[str] | None = strawberry_django.filter_field() - body_template: FilterLookup[str] | None = strawberry_django.filter_field() - secret: FilterLookup[str] | None = strawberry_django.filter_field() - ssl_verification: FilterLookup[bool] | None = strawberry_django.filter_field() - ca_file_path: FilterLookup[str] | None = strawberry_django.filter_field() - events: Annotated['EventRuleFilterV1', strawberry.lazy('extras.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.EventRule, lookups=True) -class EventRuleFilterV1( - BaseObjectTypeFilterMixinV1, CustomFieldsFilterMixinV1, TagsFilterMixinV1, ChangeLogFilterMixinV1 -): - name: FilterLookup[str] | None = strawberry_django.filter_field() - description: FilterLookup[str] | None = strawberry_django.filter_field() - event_types: Annotated['StringArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - enabled: FilterLookup[bool] | None = strawberry_django.filter_field() - conditions: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - action_type: Annotated['EventRuleActionEnum', strawberry.lazy('extras.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - action_object_type: FilterLookup[str] | None = strawberry_django.filter_field() - action_object_type_id: ID | None = strawberry_django.filter_field() - action_object_id: ID | None = strawberry_django.filter_field() - action_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - comments: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/extras/graphql/mixins_v1.py b/netbox/extras/graphql/mixins_v1.py deleted file mode 100644 index 4f56549d7..000000000 --- a/netbox/extras/graphql/mixins_v1.py +++ /dev/null @@ -1,62 +0,0 @@ -from typing import TYPE_CHECKING, Annotated, List - -import strawberry -import strawberry_django -from strawberry.types import Info - -__all__ = ( - 'ConfigContextMixinV1', - 'ContactsMixinV1', - 'CustomFieldsMixinV1', - 'ImageAttachmentsMixinV1', - 'JournalEntriesMixinV1', - 'TagsMixinV1', -) - -if TYPE_CHECKING: - from .types_v1 import ImageAttachmentTypeV1, JournalEntryTypeV1, TagTypeV1 - from tenancy.graphql.types_v1 import ContactAssignmentTypeV1 - - -@strawberry.type -class ConfigContextMixinV1: - - @strawberry_django.field - def config_context(self) -> strawberry.scalars.JSON: - return self.get_config_context() - - -@strawberry.type -class CustomFieldsMixinV1: - - @strawberry_django.field - def custom_fields(self) -> strawberry.scalars.JSON: - return self.custom_field_data - - -@strawberry.type -class ImageAttachmentsMixinV1: - - @strawberry_django.field - def image_attachments(self, info: Info) -> List[Annotated['ImageAttachmentTypeV1', strawberry.lazy('.types_v1')]]: - return self.images.restrict(info.context.request.user, 'view') - - -@strawberry.type -class JournalEntriesMixinV1: - - @strawberry_django.field - def journal_entries(self, info: Info) -> List[Annotated['JournalEntryTypeV1', strawberry.lazy('.types_v1')]]: - return self.journal_entries.all() - - -@strawberry.type -class TagsMixinV1: - - tags: List[Annotated['TagTypeV1', strawberry.lazy('.types_v1')]] - - -@strawberry.type -class ContactsMixinV1: - - contacts: List[Annotated['ContactAssignmentTypeV1', strawberry.lazy('tenancy.graphql.types_v1')]] diff --git a/netbox/extras/graphql/schema_v1.py b/netbox/extras/graphql/schema_v1.py deleted file mode 100644 index 3a2757105..000000000 --- a/netbox/extras/graphql/schema_v1.py +++ /dev/null @@ -1,60 +0,0 @@ -from typing import List - -import strawberry -import strawberry_django - -from .types_v1 import * - - -@strawberry.type(name="Query") -class ExtrasQueryV1: - config_context: ConfigContextTypeV1 = strawberry_django.field() - config_context_list: List[ConfigContextTypeV1] = strawberry_django.field() - - config_context_profile: ConfigContextProfileTypeV1 = strawberry_django.field() - config_context_profile_list: List[ConfigContextProfileTypeV1] = strawberry_django.field() - - config_template: ConfigTemplateTypeV1 = strawberry_django.field() - config_template_list: List[ConfigTemplateTypeV1] = strawberry_django.field() - - custom_field: CustomFieldTypeV1 = strawberry_django.field() - custom_field_list: List[CustomFieldTypeV1] = strawberry_django.field() - - custom_field_choice_set: CustomFieldChoiceSetTypeV1 = strawberry_django.field() - custom_field_choice_set_list: List[CustomFieldChoiceSetTypeV1] = strawberry_django.field() - - custom_link: CustomLinkTypeV1 = strawberry_django.field() - custom_link_list: List[CustomLinkTypeV1] = strawberry_django.field() - - export_template: ExportTemplateTypeV1 = strawberry_django.field() - export_template_list: List[ExportTemplateTypeV1] = strawberry_django.field() - - image_attachment: ImageAttachmentTypeV1 = strawberry_django.field() - image_attachment_list: List[ImageAttachmentTypeV1] = strawberry_django.field() - - saved_filter: SavedFilterTypeV1 = strawberry_django.field() - saved_filter_list: List[SavedFilterTypeV1] = strawberry_django.field() - - table_config: TableConfigTypeV1 = strawberry_django.field() - table_config_list: List[TableConfigTypeV1] = strawberry_django.field() - - journal_entry: JournalEntryTypeV1 = strawberry_django.field() - journal_entry_list: List[JournalEntryTypeV1] = strawberry_django.field() - - notification: NotificationTypeV1 = strawberry_django.field() - notification_list: List[NotificationTypeV1] = strawberry_django.field() - - notification_group: NotificationGroupTypeV1 = strawberry_django.field() - notification_group_list: List[NotificationGroupTypeV1] = strawberry_django.field() - - subscription: SubscriptionTypeV1 = strawberry_django.field() - subscription_list: List[SubscriptionTypeV1] = strawberry_django.field() - - tag: TagTypeV1 = strawberry_django.field() - tag_list: List[TagTypeV1] = strawberry_django.field() - - webhook: WebhookTypeV1 = strawberry_django.field() - webhook_list: List[WebhookTypeV1] = strawberry_django.field() - - event_rule: EventRuleTypeV1 = strawberry_django.field() - event_rule_list: List[EventRuleTypeV1] = strawberry_django.field() diff --git a/netbox/extras/graphql/types_v1.py b/netbox/extras/graphql/types_v1.py deleted file mode 100644 index 1693fe1da..000000000 --- a/netbox/extras/graphql/types_v1.py +++ /dev/null @@ -1,240 +0,0 @@ -from typing import Annotated, List, TYPE_CHECKING - -import strawberry -import strawberry_django - -from core.graphql.mixins_v1 import SyncedDataMixinV1 -from extras import models -from extras.graphql.mixins_v1 import CustomFieldsMixinV1, TagsMixinV1 -from netbox.graphql.types_v1 import ( - BaseObjectTypeV1, ContentTypeTypeV1, ObjectTypeV1, OrganizationalObjectTypeV1, PrimaryObjectTypeV1 -) -from users.graphql.mixins_v1 import OwnerMixinV1 -from .filters_v1 import * - -if TYPE_CHECKING: - from dcim.graphql.types_v1 import ( - DeviceRoleTypeV1, - DeviceTypeV1, - DeviceTypeTypeV1, - LocationTypeV1, - PlatformTypeV1, - RegionTypeV1, - SiteGroupTypeV1, - SiteTypeV1, - ) - from tenancy.graphql.types_v1 import TenantGroupTypeV1, TenantTypeV1 - from users.graphql.types_v1 import GroupTypeV1, UserTypeV1 - from virtualization.graphql.types_v1 import ( - ClusterGroupTypeV1, ClusterTypeV1, ClusterTypeTypeV1, VirtualMachineTypeV1 - ) - -__all__ = ( - 'ConfigContextProfileTypeV1', - 'ConfigContextTypeV1', - 'ConfigTemplateTypeV1', - 'CustomFieldChoiceSetTypeV1', - 'CustomFieldTypeV1', - 'CustomLinkTypeV1', - 'EventRuleTypeV1', - 'ExportTemplateTypeV1', - 'ImageAttachmentTypeV1', - 'JournalEntryTypeV1', - 'NotificationGroupTypeV1', - 'NotificationTypeV1', - 'SavedFilterTypeV1', - 'SubscriptionTypeV1', - 'TableConfigTypeV1', - 'TagTypeV1', - 'WebhookTypeV1', -) - - -@strawberry_django.type( - models.ConfigContextProfile, - fields='__all__', - filters=ConfigContextProfileFilterV1, - pagination=True -) -class ConfigContextProfileTypeV1(SyncedDataMixinV1, PrimaryObjectTypeV1): - pass - - -@strawberry_django.type( - models.ConfigContext, - fields='__all__', - filters=ConfigContextFilterV1, - pagination=True -) -class ConfigContextTypeV1(SyncedDataMixinV1, OwnerMixinV1, ObjectTypeV1): - profile: ConfigContextProfileTypeV1 | None - roles: List[Annotated["DeviceRoleTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - device_types: List[Annotated["DeviceTypeTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - tags: List[Annotated["TagTypeV1", strawberry.lazy('extras.graphql.types_v1')]] - platforms: List[Annotated["PlatformTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - regions: List[Annotated["RegionTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - cluster_groups: List[Annotated["ClusterGroupTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] - tenant_groups: List[Annotated["TenantGroupTypeV1", strawberry.lazy('tenancy.graphql.types_v1')]] - cluster_types: List[Annotated["ClusterTypeTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] - clusters: List[Annotated["ClusterTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] - locations: List[Annotated["LocationTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - sites: List[Annotated["SiteTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - tenants: List[Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')]] - site_groups: List[Annotated["SiteGroupTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - -@strawberry_django.type( - models.ConfigTemplate, - fields='__all__', - filters=ConfigTemplateFilterV1, - pagination=True -) -class ConfigTemplateTypeV1(SyncedDataMixinV1, OwnerMixinV1, TagsMixinV1, ObjectTypeV1): - virtualmachines: List[Annotated["VirtualMachineTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] - devices: List[Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - platforms: List[Annotated["PlatformTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - device_roles: List[Annotated["DeviceRoleTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - -@strawberry_django.type( - models.CustomField, - fields='__all__', - filters=CustomFieldFilterV1, - pagination=True -) -class CustomFieldTypeV1(OwnerMixinV1, ObjectTypeV1): - related_object_type: Annotated["ContentTypeTypeV1", strawberry.lazy('netbox.graphql.types_v1')] | None - choice_set: Annotated["CustomFieldChoiceSetTypeV1", strawberry.lazy('extras.graphql.types_v1')] | None - - -@strawberry_django.type( - models.CustomFieldChoiceSet, - exclude=['extra_choices'], - filters=CustomFieldChoiceSetFilterV1, - pagination=True -) -class CustomFieldChoiceSetTypeV1(OwnerMixinV1, ObjectTypeV1): - - choices_for: List[Annotated["CustomFieldTypeV1", strawberry.lazy('extras.graphql.types_v1')]] - extra_choices: List[List[str]] | None - - -@strawberry_django.type( - models.CustomLink, - fields='__all__', - filters=CustomLinkFilterV1, - pagination=True -) -class CustomLinkTypeV1(OwnerMixinV1, ObjectTypeV1): - pass - - -@strawberry_django.type( - models.ExportTemplate, - fields='__all__', - filters=ExportTemplateFilterV1, - pagination=True -) -class ExportTemplateTypeV1(SyncedDataMixinV1, OwnerMixinV1, ObjectTypeV1): - pass - - -@strawberry_django.type( - models.ImageAttachment, - fields='__all__', - filters=ImageAttachmentFilterV1, - pagination=True -) -class ImageAttachmentTypeV1(BaseObjectTypeV1): - object_type: Annotated["ContentTypeTypeV1", strawberry.lazy('netbox.graphql.types_v1')] | None - - -@strawberry_django.type( - models.JournalEntry, - fields='__all__', - filters=JournalEntryFilterV1, - pagination=True -) -class JournalEntryTypeV1(CustomFieldsMixinV1, TagsMixinV1, ObjectTypeV1): - assigned_object_type: Annotated["ContentTypeTypeV1", strawberry.lazy('netbox.graphql.types_v1')] | None - created_by: Annotated["UserTypeV1", strawberry.lazy('users.graphql.types_v1')] | None - - -@strawberry_django.type( - models.Notification, - # filters=NotificationFilter - pagination=True -) -class NotificationTypeV1(ObjectTypeV1): - user: Annotated["UserTypeV1", strawberry.lazy('users.graphql.types_v1')] | None - - -@strawberry_django.type( - models.NotificationGroup, - filters=NotificationGroupFilterV1, - pagination=True -) -class NotificationGroupTypeV1(ObjectTypeV1): - users: List[Annotated["UserTypeV1", strawberry.lazy('users.graphql.types_v1')]] - groups: List[Annotated["GroupTypeV1", strawberry.lazy('users.graphql.types_v1')]] - - -@strawberry_django.type( - models.SavedFilter, - exclude=['content_types',], - filters=SavedFilterFilterV1, - pagination=True -) -class SavedFilterTypeV1(OwnerMixinV1, ObjectTypeV1): - user: Annotated["UserTypeV1", strawberry.lazy('users.graphql.types_v1')] | None - - -@strawberry_django.type( - models.Subscription, - # filters=NotificationFilter - pagination=True -) -class SubscriptionTypeV1(ObjectTypeV1): - user: Annotated["UserTypeV1", strawberry.lazy('users.graphql.types_v1')] | None - - -@strawberry_django.type( - models.TableConfig, - fields='__all__', - filters=TableConfigFilterV1, - pagination=True -) -class TableConfigTypeV1(ObjectTypeV1): - user: Annotated["UserTypeV1", strawberry.lazy('users.graphql.types_v1')] | None - - -@strawberry_django.type( - models.Tag, - exclude=['extras_taggeditem_items', ], - filters=TagFilterV1, - pagination=True -) -class TagTypeV1(OwnerMixinV1, ObjectTypeV1): - color: str - - object_types: List[ContentTypeTypeV1] - - -@strawberry_django.type( - models.Webhook, - exclude=['content_types',], - filters=WebhookFilterV1, - pagination=True -) -class WebhookTypeV1(OrganizationalObjectTypeV1): - pass - - -@strawberry_django.type( - models.EventRule, - exclude=['content_types',], - filters=EventRuleFilterV1, - pagination=True -) -class EventRuleTypeV1(OrganizationalObjectTypeV1): - action_object_type: Annotated["ContentTypeTypeV1", strawberry.lazy('netbox.graphql.types_v1')] | None diff --git a/netbox/ipam/graphql/filter_mixins_v1.py b/netbox/ipam/graphql/filter_mixins_v1.py deleted file mode 100644 index 0ba314d5b..000000000 --- a/netbox/ipam/graphql/filter_mixins_v1.py +++ /dev/null @@ -1,25 +0,0 @@ -from dataclasses import dataclass -from typing import Annotated, TYPE_CHECKING - -import strawberry -import strawberry_django - -from core.graphql.filter_mixins_v1 import BaseFilterMixinV1 - -if TYPE_CHECKING: - from netbox.graphql.filter_lookups import IntegerLookup - from .enums import * - -__all__ = ( - 'ServiceBaseFilterMixinV1', -) - - -@dataclass -class ServiceBaseFilterMixinV1(BaseFilterMixinV1): - protocol: Annotated['ServiceProtocolEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - ports: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) diff --git a/netbox/ipam/graphql/filters_v1.py b/netbox/ipam/graphql/filters_v1.py deleted file mode 100644 index 7e2171e76..000000000 --- a/netbox/ipam/graphql/filters_v1.py +++ /dev/null @@ -1,392 +0,0 @@ -from datetime import date -from typing import Annotated, TYPE_CHECKING - -import netaddr -import strawberry -import strawberry_django -from django.db.models import Q -from netaddr.core import AddrFormatError -from strawberry.scalars import ID -from strawberry_django import FilterLookup, DateFilterLookup - -from core.graphql.filter_mixins_v1 import BaseObjectTypeFilterMixinV1, ChangeLogFilterMixinV1 -from dcim.graphql.filter_mixins_v1 import ScopedFilterMixinV1 -from dcim.models import Device -from ipam import models -from ipam.graphql.filter_mixins_v1 import ServiceBaseFilterMixinV1 -from netbox.graphql.filter_mixins_v1 import ( - NetBoxModelFilterMixinV1, OrganizationalModelFilterMixinV1, PrimaryModelFilterMixinV1 -) -from tenancy.graphql.filter_mixins_v1 import ContactFilterMixinV1, TenancyFilterMixinV1 -from virtualization.models import VMInterface - -if TYPE_CHECKING: - from netbox.graphql.filter_lookups import IntegerLookup, IntegerRangeArrayLookup - from circuits.graphql.filters_v1 import ProviderFilterV1 - from core.graphql.filters_v1 import ContentTypeFilterV1 - from dcim.graphql.filters_v1 import SiteFilterV1 - from vpn.graphql.filters_v1 import L2VPNFilterV1 - from .enums import * - -__all__ = ( - 'ASNFilterV1', - 'ASNRangeFilterV1', - 'AggregateFilterV1', - 'FHRPGroupFilterV1', - 'FHRPGroupAssignmentFilterV1', - 'IPAddressFilterV1', - 'IPRangeFilterV1', - 'PrefixFilterV1', - 'RIRFilterV1', - 'RoleFilterV1', - 'RouteTargetFilterV1', - 'ServiceFilterV1', - 'ServiceTemplateFilterV1', - 'VLANFilterV1', - 'VLANGroupFilterV1', - 'VLANTranslationPolicyFilterV1', - 'VLANTranslationRuleFilterV1', - 'VRFFilterV1', -) - - -@strawberry_django.filter_type(models.ASN, lookups=True) -class ASNFilterV1(TenancyFilterMixinV1, PrimaryModelFilterMixinV1): - rir: Annotated['RIRFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = strawberry_django.filter_field() - rir_id: ID | None = strawberry_django.filter_field() - asn: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - sites: ( - Annotated['SiteFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None - ) = strawberry_django.filter_field() - providers: ( - Annotated['ProviderFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None - ) = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.ASNRange, lookups=True) -class ASNRangeFilterV1(TenancyFilterMixinV1, OrganizationalModelFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - slug: FilterLookup[str] | None = strawberry_django.filter_field() - rir: Annotated['RIRFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = strawberry_django.filter_field() - rir_id: ID | None = strawberry_django.filter_field() - start: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - end: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.Aggregate, lookups=True) -class AggregateFilterV1(ContactFilterMixinV1, TenancyFilterMixinV1, PrimaryModelFilterMixinV1): - prefix: Annotated['PrefixFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - prefix_id: ID | None = strawberry_django.filter_field() - rir: Annotated['RIRFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = strawberry_django.filter_field() - rir_id: ID | None = strawberry_django.filter_field() - date_added: DateFilterLookup[date] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.FHRPGroup, lookups=True) -class FHRPGroupFilterV1(PrimaryModelFilterMixinV1): - group_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - name: FilterLookup[str] | None = strawberry_django.filter_field() - protocol: Annotated['FHRPGroupProtocolEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - auth_type: Annotated['FHRPGroupAuthTypeEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - auth_key: FilterLookup[str] | None = strawberry_django.filter_field() - ip_addresses: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.FHRPGroupAssignment, lookups=True) -class FHRPGroupAssignmentFilterV1(BaseObjectTypeFilterMixinV1, ChangeLogFilterMixinV1): - interface_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - interface_id: FilterLookup[str] | None = strawberry_django.filter_field() - group: Annotated['FHRPGroupFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - group_id: ID | None = strawberry_django.filter_field() - priority: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - - @strawberry_django.filter_field() - def device_id(self, queryset, value: list[str], prefix) -> Q: - return self.filter_device('id', value) - - @strawberry_django.filter_field() - def device(self, value: list[str], prefix) -> Q: - return self.filter_device('name', value) - - @strawberry_django.filter_field() - def virtual_machine_id(self, value: list[str], prefix) -> Q: - return Q(interface_id__in=VMInterface.objects.filter(virtual_machine_id__in=value)) - - @strawberry_django.filter_field() - def virtual_machine(self, value: list[str], prefix) -> Q: - return Q(interface_id__in=VMInterface.objects.filter(virtual_machine__name__in=value)) - - def filter_device(self, field, value) -> Q: - """Helper to standardize logic for device and device_id filters""" - devices = Device.objects.filter(**{f'{field}__in': value}) - interface_ids = [] - for device in devices: - interface_ids.extend(device.vc_interfaces().values_list('id', flat=True)) - return Q(interface_id__in=interface_ids) - - -@strawberry_django.filter_type(models.IPAddress, lookups=True) -class IPAddressFilterV1(ContactFilterMixinV1, TenancyFilterMixinV1, PrimaryModelFilterMixinV1): - address: FilterLookup[str] | None = strawberry_django.filter_field() - vrf: Annotated['VRFFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = strawberry_django.filter_field() - vrf_id: ID | None = strawberry_django.filter_field() - status: Annotated['IPAddressStatusEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - role: Annotated['IPAddressRoleEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - assigned_object_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - assigned_object_id: ID | None = strawberry_django.filter_field() - nat_inside: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - nat_inside_id: ID | None = strawberry_django.filter_field() - nat_outside: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - nat_outside_id: ID | None = strawberry_django.filter_field() - dns_name: FilterLookup[str] | None = strawberry_django.filter_field() - - @strawberry_django.filter_field() - def assigned(self, value: bool, prefix) -> Q: - return Q(assigned_object_id__isnull=(not value)) - - @strawberry_django.filter_field() - def parent(self, value: list[str], prefix) -> Q: - if not value: - return Q() - q = Q() - for subnet in value: - try: - query = str(netaddr.IPNetwork(subnet.strip()).cidr) - q |= Q(address__net_host_contained=query) - except (AddrFormatError, ValueError): - return Q() - return q - - @strawberry_django.filter_field() - def family( - self, - value: Annotated['IPAddressFamilyEnum', strawberry.lazy('ipam.graphql.enums')], - prefix, - ) -> Q: - return Q(**{f"{prefix}address__family": value.value}) - - -@strawberry_django.filter_type(models.IPRange, lookups=True) -class IPRangeFilterV1(ContactFilterMixinV1, TenancyFilterMixinV1, PrimaryModelFilterMixinV1): - start_address: FilterLookup[str] | None = strawberry_django.filter_field() - end_address: FilterLookup[str] | None = strawberry_django.filter_field() - size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - vrf: Annotated['VRFFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = strawberry_django.filter_field() - vrf_id: ID | None = strawberry_django.filter_field() - status: Annotated['IPRangeStatusEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - role: Annotated['RoleFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - mark_utilized: FilterLookup[bool] | None = strawberry_django.filter_field() - - @strawberry_django.filter_field() - def parent(self, value: list[str], prefix) -> Q: - if not value: - return Q() - q = Q() - for subnet in value: - try: - query = str(netaddr.IPNetwork(subnet.strip()).cidr) - q |= Q(start_address__net_host_contained=query, end_address__net_host_contained=query) - except (AddrFormatError, ValueError): - return Q() - return q - - @strawberry_django.filter_field() - def contains(self, value: list[str], prefix) -> Q: - if not value: - return Q() - q = Q() - for subnet in value: - net = netaddr.IPNetwork(subnet.strip()) - q |= Q( - start_address__host__inet__lte=str(netaddr.IPAddress(net.first)), - end_address__host__inet__gte=str(netaddr.IPAddress(net.last)), - ) - return q - - -@strawberry_django.filter_type(models.Prefix, lookups=True) -class PrefixFilterV1(ContactFilterMixinV1, ScopedFilterMixinV1, TenancyFilterMixinV1, PrimaryModelFilterMixinV1): - prefix: FilterLookup[str] | None = strawberry_django.filter_field() - vrf: Annotated['VRFFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = strawberry_django.filter_field() - vrf_id: ID | None = strawberry_django.filter_field() - vlan: Annotated['VLANFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - vlan_id: ID | None = strawberry_django.filter_field() - status: Annotated['PrefixStatusEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - role: Annotated['RoleFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - role_id: ID | None = strawberry_django.filter_field() - is_pool: FilterLookup[bool] | None = strawberry_django.filter_field() - mark_utilized: FilterLookup[bool] | None = strawberry_django.filter_field() - - @strawberry_django.filter_field() - def contains(self, value: list[str], prefix) -> Q: - if not value: - return Q() - q = Q() - for subnet in value: - query = str(netaddr.IPNetwork(subnet.strip()).cidr) - q |= Q(prefix__net_contains=query) - return q - - -@strawberry_django.filter_type(models.RIR, lookups=True) -class RIRFilterV1(OrganizationalModelFilterMixinV1): - is_private: FilterLookup[bool] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.Role, lookups=True) -class RoleFilterV1(OrganizationalModelFilterMixinV1): - weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.RouteTarget, lookups=True) -class RouteTargetFilterV1(TenancyFilterMixinV1, PrimaryModelFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - importing_vrfs: Annotated['VRFFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - exporting_vrfs: Annotated['VRFFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - importing_l2vpns: Annotated['L2VPNFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - exporting_l2vpns: Annotated['L2VPNFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.Service, lookups=True) -class ServiceFilterV1(ContactFilterMixinV1, ServiceBaseFilterMixinV1, PrimaryModelFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - ip_addresses: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - parent_object_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - parent_object_id: ID | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.ServiceTemplate, lookups=True) -class ServiceTemplateFilterV1(ServiceBaseFilterMixinV1, PrimaryModelFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.VLAN, lookups=True) -class VLANFilterV1(TenancyFilterMixinV1, PrimaryModelFilterMixinV1): - site: Annotated['SiteFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - site_id: ID | None = strawberry_django.filter_field() - group: Annotated['VLANGroupFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - group_id: ID | None = strawberry_django.filter_field() - vid: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - name: FilterLookup[str] | None = strawberry_django.filter_field() - status: Annotated['VLANStatusEnum', strawberry.lazy('ipam.graphql.enums')] | None = strawberry_django.filter_field() - role: Annotated['RoleFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - role_id: ID | None = strawberry_django.filter_field() - qinq_svlan: Annotated['VLANFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - qinq_svlan_id: ID | None = strawberry_django.filter_field() - qinq_cvlans: Annotated['VLANFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - qinq_role: Annotated['VLANQinQRoleEnum', strawberry.lazy('ipam.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - l2vpn_terminations: Annotated['L2VPNFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.VLANGroup, lookups=True) -class VLANGroupFilterV1(ScopedFilterMixinV1, OrganizationalModelFilterMixinV1): - vid_ranges: Annotated['IntegerRangeArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.VLANTranslationPolicy, lookups=True) -class VLANTranslationPolicyFilterV1(PrimaryModelFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.VLANTranslationRule, lookups=True) -class VLANTranslationRuleFilterV1(NetBoxModelFilterMixinV1): - policy: Annotated['VLANTranslationPolicyFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - policy_id: ID | None = strawberry_django.filter_field() - description: FilterLookup[str] | None = strawberry_django.filter_field() - local_vid: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - remote_vid: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.VRF, lookups=True) -class VRFFilterV1(TenancyFilterMixinV1, PrimaryModelFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - rd: FilterLookup[str] | None = strawberry_django.filter_field() - enforce_unique: FilterLookup[bool] | None = strawberry_django.filter_field() - import_targets: Annotated['RouteTargetFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - export_targets: Annotated['RouteTargetFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) diff --git a/netbox/ipam/graphql/mixins_v1.py b/netbox/ipam/graphql/mixins_v1.py deleted file mode 100644 index 6d3e31197..000000000 --- a/netbox/ipam/graphql/mixins_v1.py +++ /dev/null @@ -1,18 +0,0 @@ -from typing import Annotated, List - -import strawberry - -__all__ = ( - 'IPAddressesMixinV1', - 'VLANGroupsMixinV1', -) - - -@strawberry.type -class IPAddressesMixinV1: - ip_addresses: List[Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] # noqa: F821 - - -@strawberry.type -class VLANGroupsMixinV1: - vlan_groups: List[Annotated["VLANGroupTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] # noqa: F821 diff --git a/netbox/ipam/graphql/schema_v1.py b/netbox/ipam/graphql/schema_v1.py deleted file mode 100644 index 3bc7fdd97..000000000 --- a/netbox/ipam/graphql/schema_v1.py +++ /dev/null @@ -1,63 +0,0 @@ -from typing import List - -import strawberry -import strawberry_django - -from .types_v1 import * - - -@strawberry.type(name="Query") -class IPAMQueryV1: - asn: ASNTypeV1 = strawberry_django.field() - asn_list: List[ASNTypeV1] = strawberry_django.field() - - asn_range: ASNRangeTypeV1 = strawberry_django.field() - asn_range_list: List[ASNRangeTypeV1] = strawberry_django.field() - - aggregate: AggregateTypeV1 = strawberry_django.field() - aggregate_list: List[AggregateTypeV1] = strawberry_django.field() - - ip_address: IPAddressTypeV1 = strawberry_django.field() - ip_address_list: List[IPAddressTypeV1] = strawberry_django.field() - - ip_range: IPRangeTypeV1 = strawberry_django.field() - ip_range_list: List[IPRangeTypeV1] = strawberry_django.field() - - prefix: PrefixTypeV1 = strawberry_django.field() - prefix_list: List[PrefixTypeV1] = strawberry_django.field() - - rir: RIRTypeV1 = strawberry_django.field() - rir_list: List[RIRTypeV1] = strawberry_django.field() - - role: RoleTypeV1 = strawberry_django.field() - role_list: List[RoleTypeV1] = strawberry_django.field() - - route_target: RouteTargetTypeV1 = strawberry_django.field() - route_target_list: List[RouteTargetTypeV1] = strawberry_django.field() - - service: ServiceTypeV1 = strawberry_django.field() - service_list: List[ServiceTypeV1] = strawberry_django.field() - - service_template: ServiceTemplateTypeV1 = strawberry_django.field() - service_template_list: List[ServiceTemplateTypeV1] = strawberry_django.field() - - fhrp_group: FHRPGroupTypeV1 = strawberry_django.field() - fhrp_group_list: List[FHRPGroupTypeV1] = strawberry_django.field() - - fhrp_group_assignment: FHRPGroupAssignmentTypeV1 = strawberry_django.field() - fhrp_group_assignment_list: List[FHRPGroupAssignmentTypeV1] = strawberry_django.field() - - vlan: VLANTypeV1 = strawberry_django.field() - vlan_list: List[VLANTypeV1] = strawberry_django.field() - - vlan_group: VLANGroupTypeV1 = strawberry_django.field() - vlan_group_list: List[VLANGroupTypeV1] = strawberry_django.field() - - vlan_translation_policy: VLANTranslationPolicyTypeV1 = strawberry_django.field() - vlan_translation_policy_list: List[VLANTranslationPolicyTypeV1] = strawberry_django.field() - - vlan_translation_rule: VLANTranslationRuleTypeV1 = strawberry_django.field() - vlan_translation_rule_list: List[VLANTranslationRuleTypeV1] = strawberry_django.field() - - vrf: VRFTypeV1 = strawberry_django.field() - vrf_list: List[VRFTypeV1] = strawberry_django.field() diff --git a/netbox/ipam/graphql/types_v1.py b/netbox/ipam/graphql/types_v1.py deleted file mode 100644 index 091a2f215..000000000 --- a/netbox/ipam/graphql/types_v1.py +++ /dev/null @@ -1,361 +0,0 @@ -from typing import Annotated, List, TYPE_CHECKING, Union - -import strawberry -import strawberry_django - -from circuits.graphql.types_v1 import ProviderTypeV1 -from dcim.graphql.types_v1 import SiteTypeV1 -from extras.graphql.mixins_v1 import ContactsMixinV1 -from ipam import models -from netbox.graphql.scalars import BigInt -from netbox.graphql.types_v1 import ( - BaseObjectTypeV1, NetBoxObjectTypeV1, OrganizationalObjectTypeV1, PrimaryObjectTypeV1 -) -from .filters_v1 import * -from .mixins_v1 import IPAddressesMixinV1 - -if TYPE_CHECKING: - from dcim.graphql.types_v1 import ( - DeviceTypeV1, - InterfaceTypeV1, - LocationTypeV1, - RackTypeV1, - RegionTypeV1, - SiteGroupTypeV1, - SiteTypeV1, - ) - from tenancy.graphql.types_v1 import TenantTypeV1 - from virtualization.graphql.types_v1 import ( - ClusterGroupTypeV1, ClusterTypeV1, VMInterfaceTypeV1, VirtualMachineTypeV1 - ) - from vpn.graphql.types_v1 import L2VPNTypeV1, TunnelTerminationTypeV1 - from wireless.graphql.types_v1 import WirelessLANTypeV1 - -__all__ = ( - 'ASNTypeV1', - 'ASNRangeTypeV1', - 'AggregateTypeV1', - 'FHRPGroupTypeV1', - 'FHRPGroupAssignmentTypeV1', - 'IPAddressTypeV1', - 'IPRangeTypeV1', - 'PrefixTypeV1', - 'RIRTypeV1', - 'RoleTypeV1', - 'RouteTargetTypeV1', - 'ServiceTypeV1', - 'ServiceTemplateTypeV1', - 'VLANTypeV1', - 'VLANGroupTypeV1', - 'VLANTranslationPolicyTypeV1', - 'VLANTranslationRuleTypeV1', - 'VRFTypeV1', -) - - -@strawberry.type -class IPAddressFamilyTypeV1: - value: int - label: str - - -@strawberry.type -class BaseIPAddressFamilyTypeV1: - """ - Base type for models that need to expose their IPAddress family type. - """ - - @strawberry.field - def family(self) -> IPAddressFamilyTypeV1: - # Note that self, is an instance of models.IPAddress - # thus resolves to the address family value. - return IPAddressFamilyTypeV1(value=self.family, label=f'IPv{self.family}') - - -@strawberry_django.type( - models.ASN, - fields='__all__', - filters=ASNFilterV1, - pagination=True -) -class ASNTypeV1(ContactsMixinV1, PrimaryObjectTypeV1): - asn: BigInt - rir: Annotated["RIRTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None - - sites: List[SiteTypeV1] - providers: List[ProviderTypeV1] - - -@strawberry_django.type( - models.ASNRange, - fields='__all__', - filters=ASNRangeFilterV1, - pagination=True -) -class ASNRangeTypeV1(OrganizationalObjectTypeV1): - start: BigInt - end: BigInt - rir: Annotated["RIRTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None - - -@strawberry_django.type( - models.Aggregate, - fields='__all__', - filters=AggregateFilterV1, - pagination=True -) -class AggregateTypeV1(ContactsMixinV1, BaseIPAddressFamilyTypeV1, PrimaryObjectTypeV1): - prefix: str - rir: Annotated["RIRTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None - - -@strawberry_django.type( - models.FHRPGroup, - fields='__all__', - filters=FHRPGroupFilterV1, - pagination=True -) -class FHRPGroupTypeV1(IPAddressesMixinV1, PrimaryObjectTypeV1): - fhrpgroupassignment_set: List[Annotated["FHRPGroupAssignmentTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] - - -@strawberry_django.type( - models.FHRPGroupAssignment, - exclude=['interface_type', 'interface_id'], - filters=FHRPGroupAssignmentFilterV1, - pagination=True -) -class FHRPGroupAssignmentTypeV1(BaseObjectTypeV1): - group: Annotated["FHRPGroupTypeV1", strawberry.lazy('ipam.graphql.types_v1')] - - @strawberry_django.field - def interface(self) -> Annotated[Union[ - Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["VMInterfaceTypeV1", strawberry.lazy('virtualization.graphql.types_v1')], - ], strawberry.union("FHRPGroupInterfaceTypeV1")]: - return self.interface - - -@strawberry_django.type( - models.IPAddress, - exclude=['assigned_object_type', 'assigned_object_id', 'address'], - filters=IPAddressFilterV1, - pagination=True -) -class IPAddressTypeV1(ContactsMixinV1, BaseIPAddressFamilyTypeV1, PrimaryObjectTypeV1): - address: str - vrf: Annotated["VRFTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None - nat_inside: Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - - nat_outside: List[Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] - tunnel_terminations: List[Annotated["TunnelTerminationTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] - services: List[Annotated["ServiceTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] - - @strawberry_django.field - def assigned_object(self) -> Annotated[Union[ - Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["FHRPGroupTypeV1", strawberry.lazy('ipam.graphql.types_v1')], - Annotated["VMInterfaceTypeV1", strawberry.lazy('virtualization.graphql.types_v1')], - ], strawberry.union("IPAddressAssignmentTypeV1")] | None: - return self.assigned_object - - -@strawberry_django.type( - models.IPRange, - fields='__all__', - filters=IPRangeFilterV1, - pagination=True -) -class IPRangeTypeV1(ContactsMixinV1, PrimaryObjectTypeV1): - start_address: str - end_address: str - vrf: Annotated["VRFTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None - role: Annotated["RoleTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - - -@strawberry_django.type( - models.Prefix, - exclude=['scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'], - filters=PrefixFilterV1, - pagination=True -) -class PrefixTypeV1(ContactsMixinV1, BaseIPAddressFamilyTypeV1, PrimaryObjectTypeV1): - prefix: str - vrf: Annotated["VRFTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None - vlan: Annotated["VLANTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - role: Annotated["RoleTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - - @strawberry_django.field - def scope(self) -> Annotated[Union[ - Annotated["LocationTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["RegionTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["SiteGroupTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["SiteTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - ], strawberry.union("PrefixScopeTypeV1")] | None: - return self.scope - - -@strawberry_django.type( - models.RIR, - fields='__all__', - filters=RIRFilterV1, - pagination=True -) -class RIRTypeV1(OrganizationalObjectTypeV1): - - asn_ranges: List[Annotated["ASNRangeTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] - asns: List[Annotated["ASNTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] - aggregates: List[Annotated["AggregateTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] - - -@strawberry_django.type( - models.Role, - fields='__all__', - filters=RoleFilterV1, - pagination=True -) -class RoleTypeV1(OrganizationalObjectTypeV1): - - prefixes: List[Annotated["PrefixTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] - ip_ranges: List[Annotated["IPRangeTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] - vlans: List[Annotated["VLANTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] - - -@strawberry_django.type( - models.RouteTarget, - fields='__all__', - filters=RouteTargetFilterV1, - pagination=True -) -class RouteTargetTypeV1(PrimaryObjectTypeV1): - tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None - - importing_l2vpns: List[Annotated["L2VPNTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] - exporting_l2vpns: List[Annotated["L2VPNTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] - importing_vrfs: List[Annotated["VRFTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] - exporting_vrfs: List[Annotated["VRFTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] - - -@strawberry_django.type( - models.Service, - exclude=('parent_object_type', 'parent_object_id'), - filters=ServiceFilterV1, - pagination=True -) -class ServiceTypeV1(ContactsMixinV1, PrimaryObjectTypeV1): - ports: List[int] - ipaddresses: List[Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] - - @strawberry_django.field - def parent(self) -> Annotated[Union[ - Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["VirtualMachineTypeV1", strawberry.lazy('virtualization.graphql.types_v1')], - Annotated["FHRPGroupTypeV1", strawberry.lazy('ipam.graphql.types_v1')], - ], strawberry.union("ServiceParentTypeV1")] | None: - return self.parent - - -@strawberry_django.type( - models.ServiceTemplate, - fields='__all__', - filters=ServiceTemplateFilterV1, - pagination=True -) -class ServiceTemplateTypeV1(PrimaryObjectTypeV1): - ports: List[int] - - -@strawberry_django.type( - models.VLAN, - exclude=['qinq_svlan'], - filters=VLANFilterV1, - pagination=True -) -class VLANTypeV1(PrimaryObjectTypeV1): - site: Annotated["SiteTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - group: Annotated["VLANGroupTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None - role: Annotated["RoleTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - - interfaces_as_untagged: List[Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - vminterfaces_as_untagged: List[Annotated["VMInterfaceTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] - wirelesslan_set: List[Annotated["WirelessLANTypeV1", strawberry.lazy('wireless.graphql.types_v1')]] - prefixes: List[Annotated["PrefixTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] - interfaces_as_tagged: List[Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - vminterfaces_as_tagged: List[Annotated["VMInterfaceTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] - - @strawberry_django.field - def qinq_svlan(self) -> Annotated["VLANTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None: - return self.qinq_svlan - - -@strawberry_django.type( - models.VLANGroup, - exclude=['scope_type', 'scope_id'], - filters=VLANGroupFilterV1, - pagination=True -) -class VLANGroupTypeV1(OrganizationalObjectTypeV1): - - vlans: List[VLANTypeV1] - vid_ranges: List[str] - tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None - - @strawberry_django.field - def scope(self) -> Annotated[Union[ - Annotated["ClusterTypeV1", strawberry.lazy('virtualization.graphql.types_v1')], - Annotated["ClusterGroupTypeV1", strawberry.lazy('virtualization.graphql.types_v1')], - Annotated["LocationTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["RackTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["RegionTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["SiteTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["SiteGroupTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - ], strawberry.union("VLANGroupScopeTypeV1")] | None: - return self.scope - - -@strawberry_django.type( - models.VLANTranslationPolicy, - fields='__all__', - filters=VLANTranslationPolicyFilterV1, - pagination=True -) -class VLANTranslationPolicyTypeV1(PrimaryObjectTypeV1): - rules: List[Annotated["VLANTranslationRuleTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] - - -@strawberry_django.type( - models.VLANTranslationRule, - fields='__all__', - filters=VLANTranslationRuleFilterV1, - pagination=True -) -class VLANTranslationRuleTypeV1(NetBoxObjectTypeV1): - policy: Annotated[ - "VLANTranslationPolicyTypeV1", - strawberry.lazy('ipam.graphql.types_v1') - ] = strawberry_django.field(select_related=["policy"]) - - -@strawberry_django.type( - models.VRF, - fields='__all__', - filters=VRFFilterV1, - pagination=True -) -class VRFTypeV1(PrimaryObjectTypeV1): - tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None - - interfaces: List[Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - ip_addresses: List[Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] - vminterfaces: List[Annotated["VMInterfaceTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] - ip_ranges: List[Annotated["IPRangeTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] - export_targets: List[Annotated["RouteTargetTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] - import_targets: List[Annotated["RouteTargetTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] - prefixes: List[Annotated["PrefixTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] diff --git a/netbox/netbox/configuration_testing.py b/netbox/netbox/configuration_testing.py index 6256af00a..3e552e944 100644 --- a/netbox/netbox/configuration_testing.py +++ b/netbox/netbox/configuration_testing.py @@ -51,6 +51,3 @@ LOGGING = { 'version': 1, 'disable_existing_loggers': True } - -# TODO: Switch to 2 -GRAPHQL_DEFAULT_VERSION = 1 diff --git a/netbox/netbox/graphql/filter_mixins_v1.py b/netbox/netbox/graphql/filter_mixins_v1.py deleted file mode 100644 index 2d34ff12d..000000000 --- a/netbox/netbox/graphql/filter_mixins_v1.py +++ /dev/null @@ -1,104 +0,0 @@ -from dataclasses import dataclass -from datetime import datetime -from typing import TypeVar, TYPE_CHECKING, Annotated - -import strawberry -import strawberry_django -from strawberry import ID -from strawberry_django import FilterLookup, DatetimeFilterLookup - -from core.graphql.filter_mixins_v1 import BaseFilterMixinV1, BaseObjectTypeFilterMixinV1, ChangeLogFilterMixinV1 -from extras.graphql.filter_mixins_v1 import CustomFieldsFilterMixinV1, JournalEntriesFilterMixinV1, TagsFilterMixinV1 - -__all__ = ( - 'DistanceFilterMixinV1', - 'ImageAttachmentFilterMixinV1', - 'NestedGroupModelFilterMixinV1', - 'NetBoxModelFilterMixinV1', - 'OrganizationalModelFilterMixinV1', - 'PrimaryModelFilterMixinV1', - 'SyncedDataFilterMixinV1', - 'WeightFilterMixinV1', -) - -T = TypeVar('T') - - -if TYPE_CHECKING: - from .enums import * - from core.graphql.filters_v1 import * - from extras.graphql.filters_v1 import * - - -class NetBoxModelFilterMixinV1( - ChangeLogFilterMixinV1, - CustomFieldsFilterMixinV1, - JournalEntriesFilterMixinV1, - TagsFilterMixinV1, - BaseObjectTypeFilterMixinV1, -): - pass - - -@dataclass -class NestedGroupModelFilterMixinV1(NetBoxModelFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - slug: FilterLookup[str] | None = strawberry_django.filter_field() - description: FilterLookup[str] | None = strawberry_django.filter_field() - parent_id: ID | None = strawberry_django.filter_field() - - -@dataclass -class OrganizationalModelFilterMixinV1( - ChangeLogFilterMixinV1, - CustomFieldsFilterMixinV1, - TagsFilterMixinV1, - BaseObjectTypeFilterMixinV1, -): - name: FilterLookup[str] | None = strawberry_django.filter_field() - slug: FilterLookup[str] | None = strawberry_django.filter_field() - description: FilterLookup[str] | None = strawberry_django.filter_field() - - -@dataclass -class PrimaryModelFilterMixinV1(NetBoxModelFilterMixinV1): - description: FilterLookup[str] | None = strawberry_django.filter_field() - comments: FilterLookup[str] | None = strawberry_django.filter_field() - - -@dataclass -class ImageAttachmentFilterMixinV1(BaseFilterMixinV1): - images: Annotated['ImageAttachmentFilterV1', strawberry.lazy('extras.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@dataclass -class WeightFilterMixinV1(BaseFilterMixinV1): - weight: FilterLookup[float] | None = strawberry_django.filter_field() - weight_unit: Annotated['WeightUnitEnum', strawberry.lazy('netbox.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - - -@dataclass -class SyncedDataFilterMixinV1(BaseFilterMixinV1): - data_source: Annotated['DataSourceFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - data_source_id: FilterLookup[int] | None = strawberry_django.filter_field() - data_file: Annotated['DataFileFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - data_file_id: FilterLookup[int] | None = strawberry_django.filter_field() - data_path: FilterLookup[str] | None = strawberry_django.filter_field() - auto_sync_enabled: FilterLookup[bool] | None = strawberry_django.filter_field() - data_synced: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() - - -@dataclass -class DistanceFilterMixinV1(BaseFilterMixinV1): - distance: FilterLookup[float] | None = strawberry_django.filter_field() - distance_unit: Annotated['DistanceUnitEnum', strawberry.lazy('netbox.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py index 54fe61712..a7609c9d2 100644 --- a/netbox/netbox/graphql/schema.py +++ b/netbox/netbox/graphql/schema.py @@ -4,57 +4,21 @@ from strawberry_django.optimizer import DjangoOptimizerExtension from strawberry.extensions import MaxAliasesLimiter from strawberry.schema.config import StrawberryConfig -from circuits.graphql.schema_v1 import CircuitsQueryV1 from circuits.graphql.schema import CircuitsQuery -from core.graphql.schema_v1 import CoreQueryV1 from core.graphql.schema import CoreQuery -from dcim.graphql.schema_v1 import DCIMQueryV1 from dcim.graphql.schema import DCIMQuery -from extras.graphql.schema_v1 import ExtrasQueryV1 from extras.graphql.schema import ExtrasQuery -from ipam.graphql.schema_v1 import IPAMQueryV1 from ipam.graphql.schema import IPAMQuery from netbox.registry import registry -from tenancy.graphql.schema_v1 import TenancyQueryV1 from tenancy.graphql.schema import TenancyQuery -from users.graphql.schema_v1 import UsersQueryV1 from users.graphql.schema import UsersQuery -from virtualization.graphql.schema_v1 import VirtualizationQueryV1 from virtualization.graphql.schema import VirtualizationQuery -from vpn.graphql.schema_v1 import VPNQueryV1 from vpn.graphql.schema import VPNQuery -from wireless.graphql.schema_v1 import WirelessQueryV1 from wireless.graphql.schema import WirelessQuery -__all__ = ( - 'Query', - 'QueryV1', - 'QueryV2', - 'schema_v1', - 'schema_v2', -) - @strawberry.type -class QueryV1( - UsersQueryV1, - CircuitsQueryV1, - CoreQueryV1, - DCIMQueryV1, - ExtrasQueryV1, - IPAMQueryV1, - TenancyQueryV1, - VirtualizationQueryV1, - VPNQueryV1, - WirelessQueryV1, - *registry['plugins']['graphql_schemas'], # Append plugin schemas -): - """Query class for GraphQL API v1""" - pass - - -@strawberry.type -class QueryV2( +class Query( UsersQuery, CircuitsQuery, CoreQuery, @@ -67,26 +31,11 @@ class QueryV2( WirelessQuery, *registry['plugins']['graphql_schemas'], # Append plugin schemas ): - """Query class for GraphQL API v2""" pass -# Expose a default Query class for the configured default GraphQL version -class Query(QueryV2 if settings.GRAPHQL_DEFAULT_VERSION == 2 else QueryV1): - pass - - -# Generate schemas for both versions of the GraphQL API -schema_v1 = strawberry.Schema( - query=QueryV1, - config=StrawberryConfig(auto_camel_case=False), - extensions=[ - DjangoOptimizerExtension(prefetch_custom_queryset=True), - MaxAliasesLimiter(max_alias_count=settings.GRAPHQL_MAX_ALIASES), - ] -) -schema_v2 = strawberry.Schema( - query=QueryV2, +schema = strawberry.Schema( + query=Query, config=StrawberryConfig(auto_camel_case=False), extensions=[ DjangoOptimizerExtension(prefetch_custom_queryset=True), diff --git a/netbox/netbox/graphql/types_v1.py b/netbox/netbox/graphql/types_v1.py deleted file mode 100644 index 5fc776d8d..000000000 --- a/netbox/netbox/graphql/types_v1.py +++ /dev/null @@ -1,133 +0,0 @@ -import strawberry -import strawberry_django -from strawberry.types import Info -from django.contrib.contenttypes.models import ContentType - -from core.graphql.mixins_v1 import ChangelogMixinV1 -from core.models import ObjectType as ObjectType_ -from extras.graphql.mixins_v1 import CustomFieldsMixinV1, JournalEntriesMixinV1, TagsMixinV1 -from users.graphql.mixins_v1 import OwnerMixinV1 - -__all__ = ( - 'BaseObjectTypeV1', - 'ContentTypeTypeV1', - 'NestedGroupObjectTypeV1', - 'NetBoxObjectTypeV1', - 'ObjectTypeV1', - 'OrganizationalObjectTypeV1', - 'PrimaryObjectTypeV1', -) - - -# -# Base types -# - -@strawberry.type -class BaseObjectTypeV1: - """ - Base GraphQL object type for all NetBox objects. Restricts the model queryset to enforce object permissions. - """ - - @classmethod - def get_queryset(cls, queryset, info: Info, **kwargs): - # Enforce object permissions on the queryset - if hasattr(queryset, 'restrict'): - return queryset.restrict(info.context.request.user, 'view') - else: - return queryset - - @strawberry_django.field - def display(self) -> str: - return str(self) - - @strawberry_django.field - def class_type(self) -> str: - return self.__class__.__name__ - - -class ObjectTypeV1( - ChangelogMixinV1, - BaseObjectTypeV1 -): - """ - Base GraphQL object type for unclassified models which support change logging - """ - pass - - -class PrimaryObjectTypeV1( - ChangelogMixinV1, - CustomFieldsMixinV1, - JournalEntriesMixinV1, - TagsMixinV1, - OwnerMixinV1, - BaseObjectTypeV1 -): - """ - Base GraphQL type for models which inherit from PrimaryModel. - """ - pass - - -class OrganizationalObjectTypeV1( - ChangelogMixinV1, - CustomFieldsMixinV1, - JournalEntriesMixinV1, - TagsMixinV1, - OwnerMixinV1, - BaseObjectTypeV1 -): - """ - Base type for organizational models - """ - pass - - -class NestedGroupObjectTypeV1( - ChangelogMixinV1, - CustomFieldsMixinV1, - JournalEntriesMixinV1, - TagsMixinV1, - OwnerMixinV1, - BaseObjectTypeV1 -): - """ - Base GraphQL type for models which inherit from NestedGroupModel. - """ - pass - - -class NetBoxObjectTypeV1( - ChangelogMixinV1, - CustomFieldsMixinV1, - JournalEntriesMixinV1, - TagsMixinV1, - BaseObjectTypeV1 -): - """ - GraphQL type for most NetBox models. Includes support for custom fields, change logging, journaling, and tags. - """ - pass - - -# -# Miscellaneous types -# - -@strawberry_django.type( - ContentType, - fields=['id', 'app_label', 'model'], - pagination=True -) -class ContentTypeTypeV1: - pass - - -@strawberry_django.type( - ObjectType_, - fields=['id', 'app_label', 'model'], - pagination=True -) -class ObjectTypeTypeV1: - pass diff --git a/netbox/netbox/graphql/utils.py b/netbox/netbox/graphql/utils.py deleted file mode 100644 index b97b8cecc..000000000 --- a/netbox/netbox/graphql/utils.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.conf import settings - -from netbox.graphql.schema import schema_v1, schema_v2 - -__all__ = ( - 'get_default_schema', -) - - -def get_default_schema(): - """ - Returns the GraphQL schema corresponding to the value of the NETBOX_GRAPHQL_DEFAULT_SCHEMA setting. - """ - if settings.GRAPHQL_DEFAULT_VERSION == 2: - return schema_v2 - return schema_v1 diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 437689732..15d5b39b1 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -6,8 +6,7 @@ from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, Spec from account.views import LoginView, LogoutView from netbox.api.views import APIRootView, StatusView -from netbox.graphql.schema import schema_v1, schema_v2 -from netbox.graphql.utils import get_default_schema +from netbox.graphql.schema import schema from netbox.graphql.views import NetBoxGraphQLView from netbox.plugins.urls import plugin_patterns, plugin_api_patterns from netbox.views import HomeView, MediaView, StaticMediaFailureView, SearchView, htmx @@ -66,10 +65,8 @@ _patterns = [ path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='api_docs'), path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='api_redocs'), - # GraphQL API - path('graphql/', NetBoxGraphQLView.as_view(schema=get_default_schema()), name='graphql'), - path('graphql/v1/', NetBoxGraphQLView.as_view(schema=schema_v1), name='graphql_v1'), - path('graphql/v2/', NetBoxGraphQLView.as_view(schema=schema_v2), name='graphql_v2'), + # GraphQL + path('graphql/', NetBoxGraphQLView.as_view(schema=schema), name='graphql'), # Serving static media in Django to pipe it through LoginRequiredMiddleware path('media/', MediaView.as_view(), name='media'), diff --git a/netbox/tenancy/graphql/filter_mixins_v1.py b/netbox/tenancy/graphql/filter_mixins_v1.py deleted file mode 100644 index c5ffd914f..000000000 --- a/netbox/tenancy/graphql/filter_mixins_v1.py +++ /dev/null @@ -1,38 +0,0 @@ -from dataclasses import dataclass -from typing import Annotated, TYPE_CHECKING - -import strawberry -import strawberry_django -from strawberry import ID - -from core.graphql.filter_mixins_v1 import BaseFilterMixinV1 - -if TYPE_CHECKING: - from netbox.graphql.filter_lookups import TreeNodeFilter - from .filters_v1 import ContactAssignmentFilterV1, TenantFilterV1, TenantGroupFilterV1 - -__all__ = ( - 'ContactFilterMixinV1', - 'TenancyFilterMixinV1', -) - - -@dataclass -class ContactFilterMixinV1(BaseFilterMixinV1): - contacts: Annotated['ContactAssignmentFilterV1', strawberry.lazy('tenancy.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@dataclass -class TenancyFilterMixinV1(BaseFilterMixinV1): - tenant: Annotated['TenantFilterV1', strawberry.lazy('tenancy.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - tenant_id: ID | None = strawberry_django.filter_field() - tenant_group: Annotated['TenantGroupFilterV1', strawberry.lazy('tenancy.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - tenant_group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) diff --git a/netbox/tenancy/graphql/filters_v1.py b/netbox/tenancy/graphql/filters_v1.py deleted file mode 100644 index fb101858b..000000000 --- a/netbox/tenancy/graphql/filters_v1.py +++ /dev/null @@ -1,210 +0,0 @@ -from typing import Annotated, TYPE_CHECKING - -import strawberry -import strawberry_django -from strawberry.scalars import ID -from strawberry_django import FilterLookup - -from core.graphql.filter_mixins_v1 import ChangeLogFilterMixinV1 -from extras.graphql.filter_mixins_v1 import CustomFieldsFilterMixinV1, TagsFilterMixinV1 -from netbox.graphql.filter_mixins_v1 import ( - NestedGroupModelFilterMixinV1, - OrganizationalModelFilterMixinV1, - PrimaryModelFilterMixinV1, -) -from tenancy import models -from .filter_mixins_v1 import ContactFilterMixinV1 - -if TYPE_CHECKING: - from core.graphql.filters_v1 import ContentTypeFilterV1 - from circuits.graphql.filters_v1 import CircuitFilterV1, CircuitGroupFilterV1, VirtualCircuitFilterV1 - from dcim.graphql.filters_v1 import ( - CableFilterV1, - DeviceFilterV1, - LocationFilterV1, - PowerFeedFilterV1, - RackFilterV1, - RackReservationFilterV1, - SiteFilterV1, - VirtualDeviceContextFilterV1, - ) - from ipam.graphql.filters_v1 import ( - AggregateFilterV1, - ASNFilterV1, - ASNRangeFilterV1, - IPAddressFilterV1, - IPRangeFilterV1, - PrefixFilterV1, - RouteTargetFilterV1, - VLANFilterV1, - VLANGroupFilterV1, - VRFFilterV1, - ) - from netbox.graphql.filter_lookups import TreeNodeFilter - from wireless.graphql.filters_v1 import WirelessLANFilterV1, WirelessLinkFilterV1 - from virtualization.graphql.filters_v1 import ClusterFilterV1, VirtualMachineFilterV1 - from vpn.graphql.filters_v1 import L2VPNFilterV1, TunnelFilterV1 - from .enums import * - -__all__ = ( - 'TenantFilterV1', - 'TenantGroupFilterV1', - 'ContactFilterV1', - 'ContactRoleFilterV1', - 'ContactGroupFilterV1', - 'ContactAssignmentFilterV1', -) - - -@strawberry_django.filter_type(models.Tenant, lookups=True) -class TenantFilterV1(PrimaryModelFilterMixinV1, ContactFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - slug: FilterLookup[str] | None = strawberry_django.filter_field() - group: Annotated['TenantGroupFilterV1', strawberry.lazy('tenancy.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - - # Reverse relations - aggregates: Annotated['AggregateFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - asns: Annotated['ASNFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = strawberry_django.filter_field() - asn_ranges: Annotated['ASNRangeFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - cables: Annotated['CableFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - circuit_groups: Annotated['CircuitGroupFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - circuits: Annotated['CircuitFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - clusters: Annotated['ClusterFilterV1', strawberry.lazy('virtualization.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - devices: Annotated['DeviceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - ip_addresses: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - ip_ranges: Annotated['IPRangeFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - l2vpns: Annotated['L2VPNFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - locations: Annotated['LocationFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - power_feeds: Annotated['PowerFeedFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - prefixes: Annotated['PrefixFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - racks: Annotated['RackFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - rackreservations: Annotated['RackReservationFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - route_targets: Annotated['RouteTargetFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - sites: Annotated['SiteFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - tunnels: Annotated['TunnelFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - vdcs: Annotated['VirtualDeviceContextFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - virtual_machines: Annotated[ - 'VirtualMachineFilterV1', strawberry.lazy('virtualization.graphql.filters_v1') - ] | None = ( - strawberry_django.filter_field() - ) - vlan_groups: Annotated['VLANGroupFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - vlans: Annotated['VLANFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - virtual_circuits: Annotated['VirtualCircuitFilterV1', strawberry.lazy('circuits.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - vrfs: Annotated['VRFFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = strawberry_django.filter_field() - wireless_lans: Annotated['WirelessLANFilterV1', strawberry.lazy('wireless.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - wireless_links: Annotated['WirelessLinkFilterV1', strawberry.lazy('wireless.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.TenantGroup, lookups=True) -class TenantGroupFilterV1(OrganizationalModelFilterMixinV1): - parent: Annotated['TenantGroupFilterV1', strawberry.lazy('tenancy.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - parent_id: ID | None = strawberry.UNSET - tenants: Annotated['TenantFilterV1', strawberry.lazy('tenancy.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - children: Annotated['TenantGroupFilterV1', strawberry.lazy('tenancy.graphql.filters_v1'), True] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.Contact, lookups=True) -class ContactFilterV1(PrimaryModelFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - title: FilterLookup[str] | None = strawberry_django.filter_field() - phone: FilterLookup[str] | None = strawberry_django.filter_field() - email: FilterLookup[str] | None = strawberry_django.filter_field() - address: FilterLookup[str] | None = strawberry_django.filter_field() - link: FilterLookup[str] | None = strawberry_django.filter_field() - groups: Annotated['ContactGroupFilterV1', strawberry.lazy('tenancy.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - assignments: Annotated['ContactAssignmentFilterV1', strawberry.lazy('tenancy.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.ContactRole, lookups=True) -class ContactRoleFilterV1(OrganizationalModelFilterMixinV1): - pass - - -@strawberry_django.filter_type(models.ContactGroup, lookups=True) -class ContactGroupFilterV1(NestedGroupModelFilterMixinV1): - parent: Annotated['ContactGroupFilterV1', strawberry.lazy('tenancy.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.ContactAssignment, lookups=True) -class ContactAssignmentFilterV1(CustomFieldsFilterMixinV1, TagsFilterMixinV1, ChangeLogFilterMixinV1): - object_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - object_id: ID | None = strawberry_django.filter_field() - contact: Annotated['ContactFilterV1', strawberry.lazy('tenancy.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - contact_id: ID | None = strawberry_django.filter_field() - role: Annotated['ContactRoleFilterV1', strawberry.lazy('tenancy.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - role_id: ID | None = strawberry_django.filter_field() - priority: Annotated['ContactPriorityEnum', strawberry.lazy('tenancy.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) diff --git a/netbox/tenancy/graphql/mixins_v1.py b/netbox/tenancy/graphql/mixins_v1.py deleted file mode 100644 index a6c31b68a..000000000 --- a/netbox/tenancy/graphql/mixins_v1.py +++ /dev/null @@ -1,12 +0,0 @@ -from typing import Annotated, List - -import strawberry - -__all__ = ( - 'ContactAssignmentsMixinV1', -) - - -@strawberry.type -class ContactAssignmentsMixinV1: - assignments: List[Annotated["ContactAssignmentTypeV1", strawberry.lazy('tenancy.graphql.types_v1')]] # noqa: F821 diff --git a/netbox/tenancy/graphql/schema_v1.py b/netbox/tenancy/graphql/schema_v1.py deleted file mode 100644 index 135f1573f..000000000 --- a/netbox/tenancy/graphql/schema_v1.py +++ /dev/null @@ -1,27 +0,0 @@ -from typing import List - -import strawberry -import strawberry_django - -from .types_v1 import * - - -@strawberry.type(name="Query") -class TenancyQueryV1: - tenant: TenantTypeV1 = strawberry_django.field() - tenant_list: List[TenantTypeV1] = strawberry_django.field() - - tenant_group: TenantGroupTypeV1 = strawberry_django.field() - tenant_group_list: List[TenantGroupTypeV1] = strawberry_django.field() - - contact: ContactTypeV1 = strawberry_django.field() - contact_list: List[ContactTypeV1] = strawberry_django.field() - - contact_role: ContactRoleTypeV1 = strawberry_django.field() - contact_role_list: List[ContactRoleTypeV1] = strawberry_django.field() - - contact_group: ContactGroupTypeV1 = strawberry_django.field() - contact_group_list: List[ContactGroupTypeV1] = strawberry_django.field() - - contact_assignment: ContactAssignmentTypeV1 = strawberry_django.field() - contact_assignment_list: List[ContactAssignmentTypeV1] = strawberry_django.field() diff --git a/netbox/tenancy/graphql/types_v1.py b/netbox/tenancy/graphql/types_v1.py deleted file mode 100644 index 82e7d7610..000000000 --- a/netbox/tenancy/graphql/types_v1.py +++ /dev/null @@ -1,149 +0,0 @@ -from typing import Annotated, List, TYPE_CHECKING - -import strawberry -import strawberry_django - -from extras.graphql.mixins_v1 import CustomFieldsMixinV1, TagsMixinV1, ContactsMixinV1 -from netbox.graphql.types_v1 import ( - BaseObjectTypeV1, OrganizationalObjectTypeV1, PrimaryObjectTypeV1 -) -from tenancy import models -from .filters_v1 import * -from .mixins_v1 import ContactAssignmentsMixinV1 - -if TYPE_CHECKING: - from circuits.graphql.types_v1 import CircuitTypeV1 - from dcim.graphql.types_v1 import ( - CableTypeV1, - DeviceTypeV1, - LocationTypeV1, - PowerFeedTypeV1, - RackTypeV1, - RackReservationTypeV1, - SiteTypeV1, - VirtualDeviceContextTypeV1, - ) - from ipam.graphql.types_v1 import ( - AggregateTypeV1, - ASNTypeV1, - ASNRangeTypeV1, - IPAddressTypeV1, - IPRangeTypeV1, - PrefixTypeV1, - RouteTargetTypeV1, - VLANTypeV1, - VRFTypeV1, - ) - from netbox.graphql.types_v1 import ContentTypeTypeV1 - from wireless.graphql.types_v1 import WirelessLANTypeV1, WirelessLinkTypeV1 - from virtualization.graphql.types_v1 import ClusterTypeV1, VirtualMachineTypeV1 - from vpn.graphql.types_v1 import L2VPNTypeV1, TunnelTypeV1 - -__all__ = ( - 'ContactAssignmentTypeV1', - 'ContactGroupTypeV1', - 'ContactRoleTypeV1', - 'ContactTypeV1', - 'TenantTypeV1', - 'TenantGroupTypeV1', -) - - -# -# Tenants -# - -@strawberry_django.type( - models.Tenant, - fields='__all__', - filters=TenantFilterV1, - pagination=True -) -class TenantTypeV1(ContactsMixinV1, PrimaryObjectTypeV1): - group: Annotated['TenantGroupTypeV1', strawberry.lazy('tenancy.graphql.types_v1')] | None - asns: List[Annotated['ASNTypeV1', strawberry.lazy('ipam.graphql.types_v1')]] - circuits: List[Annotated['CircuitTypeV1', strawberry.lazy('circuits.graphql.types_v1')]] - sites: List[Annotated['SiteTypeV1', strawberry.lazy('dcim.graphql.types_v1')]] - vlans: List[Annotated['VLANTypeV1', strawberry.lazy('ipam.graphql.types_v1')]] - wireless_lans: List[Annotated['WirelessLANTypeV1', strawberry.lazy('wireless.graphql.types_v1')]] - route_targets: List[Annotated['RouteTargetTypeV1', strawberry.lazy('ipam.graphql.types_v1')]] - locations: List[Annotated['LocationTypeV1', strawberry.lazy('dcim.graphql.types_v1')]] - ip_ranges: List[Annotated['IPRangeTypeV1', strawberry.lazy('ipam.graphql.types_v1')]] - rackreservations: List[Annotated['RackReservationTypeV1', strawberry.lazy('dcim.graphql.types_v1')]] - racks: List[Annotated['RackTypeV1', strawberry.lazy('dcim.graphql.types_v1')]] - vdcs: List[Annotated['VirtualDeviceContextTypeV1', strawberry.lazy('dcim.graphql.types_v1')]] - prefixes: List[Annotated['PrefixTypeV1', strawberry.lazy('ipam.graphql.types_v1')]] - cables: List[Annotated['CableTypeV1', strawberry.lazy('dcim.graphql.types_v1')]] - virtual_machines: List[Annotated['VirtualMachineTypeV1', strawberry.lazy('virtualization.graphql.types_v1')]] - vrfs: List[Annotated['VRFTypeV1', strawberry.lazy('ipam.graphql.types_v1')]] - asn_ranges: List[Annotated['ASNRangeTypeV1', strawberry.lazy('ipam.graphql.types_v1')]] - wireless_links: List[Annotated['WirelessLinkTypeV1', strawberry.lazy('wireless.graphql.types_v1')]] - aggregates: List[Annotated['AggregateTypeV1', strawberry.lazy('ipam.graphql.types_v1')]] - power_feeds: List[Annotated['PowerFeedTypeV1', strawberry.lazy('dcim.graphql.types_v1')]] - devices: List[Annotated['DeviceTypeV1', strawberry.lazy('dcim.graphql.types_v1')]] - tunnels: List[Annotated['TunnelTypeV1', strawberry.lazy('vpn.graphql.types_v1')]] - ip_addresses: List[Annotated['IPAddressTypeV1', strawberry.lazy('ipam.graphql.types_v1')]] - clusters: List[Annotated['ClusterTypeV1', strawberry.lazy('virtualization.graphql.types_v1')]] - l2vpns: List[Annotated['L2VPNTypeV1', strawberry.lazy('vpn.graphql.types_v1')]] - - -@strawberry_django.type( - models.TenantGroup, - fields='__all__', - filters=TenantGroupFilterV1, - pagination=True -) -class TenantGroupTypeV1(OrganizationalObjectTypeV1): - parent: Annotated['TenantGroupTypeV1', strawberry.lazy('tenancy.graphql.types_v1')] | None - - tenants: List[TenantTypeV1] - children: List[Annotated['TenantGroupTypeV1', strawberry.lazy('tenancy.graphql.types_v1')]] - - -# -# Contacts -# - -@strawberry_django.type( - models.Contact, - fields='__all__', - filters=ContactFilterV1, - pagination=True -) -class ContactTypeV1(ContactAssignmentsMixinV1, PrimaryObjectTypeV1): - groups: List[Annotated['ContactGroupTypeV1', strawberry.lazy('tenancy.graphql.types_v1')]] - - -@strawberry_django.type( - models.ContactRole, - fields='__all__', - filters=ContactRoleFilterV1, - pagination=True -) -class ContactRoleTypeV1(ContactAssignmentsMixinV1, OrganizationalObjectTypeV1): - pass - - -@strawberry_django.type( - models.ContactGroup, - fields='__all__', - filters=ContactGroupFilterV1, - pagination=True -) -class ContactGroupTypeV1(OrganizationalObjectTypeV1): - parent: Annotated['ContactGroupTypeV1', strawberry.lazy('tenancy.graphql.types_v1')] | None - - contacts: List[ContactTypeV1] - children: List[Annotated['ContactGroupTypeV1', strawberry.lazy('tenancy.graphql.types_v1')]] - - -@strawberry_django.type( - models.ContactAssignment, - fields='__all__', - filters=ContactAssignmentFilterV1, - pagination=True -) -class ContactAssignmentTypeV1(CustomFieldsMixinV1, TagsMixinV1, BaseObjectTypeV1): - object_type: Annotated['ContentTypeTypeV1', strawberry.lazy('netbox.graphql.types_v1')] | None - contact: Annotated['ContactTypeV1', strawberry.lazy('tenancy.graphql.types_v1')] | None - role: Annotated['ContactRoleTypeV1', strawberry.lazy('tenancy.graphql.types_v1')] | None diff --git a/netbox/users/graphql/filters_v1.py b/netbox/users/graphql/filters_v1.py deleted file mode 100644 index 951c666ae..000000000 --- a/netbox/users/graphql/filters_v1.py +++ /dev/null @@ -1,57 +0,0 @@ -from datetime import datetime -from typing import Annotated - -import strawberry -import strawberry_django -from strawberry_django import DatetimeFilterLookup, FilterLookup - -from core.graphql.filter_mixins_v1 import BaseObjectTypeFilterMixinV1 -from users import models - -__all__ = ( - 'GroupFilterV1', - 'OwnerFilterV1', - 'OwnerGroupFilterV1', - 'UserFilterV1', -) - - -@strawberry_django.filter_type(models.Group, lookups=True) -class GroupFilterV1(BaseObjectTypeFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - description: FilterLookup[str] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.User, lookups=True) -class UserFilterV1(BaseObjectTypeFilterMixinV1): - username: FilterLookup[str] | None = strawberry_django.filter_field() - first_name: FilterLookup[str] | None = strawberry_django.filter_field() - last_name: FilterLookup[str] | None = strawberry_django.filter_field() - email: FilterLookup[str] | None = strawberry_django.filter_field() - is_superuser: FilterLookup[bool] | None = strawberry_django.filter_field() - is_active: FilterLookup[bool] | None = strawberry_django.filter_field() - date_joined: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() - last_login: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() - groups: Annotated['GroupFilterV1', strawberry.lazy('users.graphql.filters_v1')] | None = ( - strawberry_django.filter_field()) - - -@strawberry_django.filter_type(models.Owner, lookups=True) -class OwnerFilterV1(BaseObjectTypeFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - description: FilterLookup[str] | None = strawberry_django.filter_field() - group: Annotated['OwnerGroupFilterV1', strawberry.lazy('users.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - user_groups: Annotated['GroupFilterV1', strawberry.lazy('users.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - users: Annotated['UserFilterV1', strawberry.lazy('users.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.OwnerGroup, lookups=True) -class OwnerGroupFilterV1(BaseObjectTypeFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - description: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/users/graphql/mixins_v1.py b/netbox/users/graphql/mixins_v1.py deleted file mode 100644 index b2fb22e6d..000000000 --- a/netbox/users/graphql/mixins_v1.py +++ /dev/null @@ -1,15 +0,0 @@ -from typing import Annotated, TYPE_CHECKING - -import strawberry - -if TYPE_CHECKING: - from users.graphql.types_v1 import OwnerTypeV1 - -__all__ = ( - 'OwnerMixinV1', -) - - -@strawberry.type -class OwnerMixinV1: - owner: Annotated['OwnerTypeV1', strawberry.lazy('users.graphql.types_v1')] | None diff --git a/netbox/users/graphql/schema_v1.py b/netbox/users/graphql/schema_v1.py deleted file mode 100644 index b5389f35e..000000000 --- a/netbox/users/graphql/schema_v1.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import List - -import strawberry -import strawberry_django - -from .types_v1 import * - - -@strawberry.type(name="Query") -class UsersQueryV1: - group: GroupTypeV1 = strawberry_django.field() - group_list: List[GroupTypeV1] = strawberry_django.field() - - user: UserTypeV1 = strawberry_django.field() - user_list: List[UserTypeV1] = strawberry_django.field() - - owner_group: OwnerGroupTypeV1 = strawberry_django.field() - owner_group_list: List[OwnerGroupTypeV1] = strawberry_django.field() - - owner: OwnerTypeV1 = strawberry_django.field() - owner_list: List[OwnerTypeV1] = strawberry_django.field() diff --git a/netbox/users/graphql/types_v1.py b/netbox/users/graphql/types_v1.py deleted file mode 100644 index 16b0aecde..000000000 --- a/netbox/users/graphql/types_v1.py +++ /dev/null @@ -1,56 +0,0 @@ -from typing import List - -import strawberry_django - -from netbox.graphql.types_v1 import BaseObjectTypeV1 -from users.models import Group, Owner, OwnerGroup, User -from .filters_v1 import * - -__all__ = ( - 'GroupTypeV1', - 'OwnerGroupTypeV1', - 'OwnerTypeV1', - 'UserTypeV1', -) - - -@strawberry_django.type( - Group, - fields=['id', 'name'], - filters=GroupFilterV1, - pagination=True -) -class GroupTypeV1(BaseObjectTypeV1): - pass - - -@strawberry_django.type( - User, - fields=[ - 'id', 'username', 'first_name', 'last_name', 'email', 'is_active', 'date_joined', 'groups', - ], - filters=UserFilterV1, - pagination=True -) -class UserTypeV1(BaseObjectTypeV1): - groups: List[GroupTypeV1] - - -@strawberry_django.type( - OwnerGroup, - fields=['id', 'name', 'description'], - filters=OwnerGroupFilterV1, - pagination=True -) -class OwnerGroupTypeV1(BaseObjectTypeV1): - pass - - -@strawberry_django.type( - Owner, - fields=['id', 'group', 'name', 'description', 'user_groups', 'users'], - filters=OwnerFilterV1, - pagination=True -) -class OwnerTypeV1(BaseObjectTypeV1): - group: OwnerGroupTypeV1 | None diff --git a/netbox/virtualization/graphql/filter_mixins_v1.py b/netbox/virtualization/graphql/filter_mixins_v1.py deleted file mode 100644 index 7de4192c8..000000000 --- a/netbox/virtualization/graphql/filter_mixins_v1.py +++ /dev/null @@ -1,28 +0,0 @@ -from dataclasses import dataclass -from typing import Annotated, TYPE_CHECKING - -import strawberry -import strawberry_django -from strawberry import ID -from strawberry_django import FilterLookup - -from netbox.graphql.filter_mixins_v1 import NetBoxModelFilterMixinV1 - -if TYPE_CHECKING: - from .filters_v1 import VirtualMachineFilterV1 - -__all__ = ( - 'VMComponentFilterMixinV1', -) - - -@dataclass -class VMComponentFilterMixinV1(NetBoxModelFilterMixinV1): - virtual_machine: Annotated[ - 'VirtualMachineFilterV1', strawberry.lazy('virtualization.graphql.filters_v1') - ] | None = ( - strawberry_django.filter_field() - ) - virtual_machine_id: ID | None = strawberry_django.filter_field() - name: FilterLookup[str] | None = strawberry_django.filter_field() - description: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/virtualization/graphql/filters_v1.py b/netbox/virtualization/graphql/filters_v1.py deleted file mode 100644 index d1ba0cff4..000000000 --- a/netbox/virtualization/graphql/filters_v1.py +++ /dev/null @@ -1,170 +0,0 @@ -from typing import Annotated, TYPE_CHECKING - -import strawberry -import strawberry_django -from strawberry.scalars import ID -from strawberry_django import FilterLookup - -from dcim.graphql.filter_mixins_v1 import InterfaceBaseFilterMixinV1, RenderConfigFilterMixinV1, ScopedFilterMixinV1 -from extras.graphql.filter_mixins_v1 import ConfigContextFilterMixinV1 -from netbox.graphql.filter_mixins_v1 import ( - ImageAttachmentFilterMixinV1, - OrganizationalModelFilterMixinV1, - PrimaryModelFilterMixinV1, -) -from tenancy.graphql.filter_mixins_v1 import ContactFilterMixinV1, TenancyFilterMixinV1 -from virtualization import models -from virtualization.graphql.filter_mixins_v1 import VMComponentFilterMixinV1 - -if TYPE_CHECKING: - from .enums import * - from netbox.graphql.filter_lookups import FloatLookup, IntegerLookup - from dcim.graphql.filters_v1 import ( - DeviceFilterV1, DeviceRoleFilterV1, MACAddressFilterV1, PlatformFilterV1, SiteFilterV1 - ) - from ipam.graphql.filters_v1 import ( - FHRPGroupAssignmentFilterV1, - IPAddressFilterV1, - ServiceFilterV1, - VLANGroupFilterV1, - VRFFilterV1, - ) - from vpn.graphql.filters_v1 import L2VPNFilterV1, TunnelTerminationFilterV1 - -__all__ = ( - 'ClusterFilterV1', - 'ClusterGroupFilterV1', - 'ClusterTypeFilterV1', - 'VirtualMachineFilterV1', - 'VMInterfaceFilterV1', - 'VirtualDiskFilterV1', -) - - -@strawberry_django.filter_type(models.Cluster, lookups=True) -class ClusterFilterV1(ContactFilterMixinV1, ScopedFilterMixinV1, TenancyFilterMixinV1, PrimaryModelFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - type: Annotated['ClusterTypeFilterV1', strawberry.lazy('virtualization.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - type_id: ID | None = strawberry_django.filter_field() - group: Annotated['ClusterGroupFilterV1', strawberry.lazy('virtualization.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - group_id: ID | None = strawberry_django.filter_field() - status: Annotated['ClusterStatusEnum', strawberry.lazy('virtualization.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - vlan_groups: Annotated['VLANGroupFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.ClusterGroup, lookups=True) -class ClusterGroupFilterV1(ContactFilterMixinV1, OrganizationalModelFilterMixinV1): - vlan_groups: Annotated['VLANGroupFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.ClusterType, lookups=True) -class ClusterTypeFilterV1(OrganizationalModelFilterMixinV1): - pass - - -@strawberry_django.filter_type(models.VirtualMachine, lookups=True) -class VirtualMachineFilterV1( - ContactFilterMixinV1, - ImageAttachmentFilterMixinV1, - RenderConfigFilterMixinV1, - ConfigContextFilterMixinV1, - TenancyFilterMixinV1, - PrimaryModelFilterMixinV1, -): - name: FilterLookup[str] | None = strawberry_django.filter_field() - site: Annotated['SiteFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - site_id: ID | None = strawberry_django.filter_field() - cluster: Annotated['ClusterFilterV1', strawberry.lazy('virtualization.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - cluster_id: ID | None = strawberry_django.filter_field() - device: Annotated['DeviceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - device_id: ID | None = strawberry_django.filter_field() - platform: Annotated['PlatformFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - platform_id: ID | None = strawberry_django.filter_field() - status: Annotated['VirtualMachineStatusEnum', strawberry.lazy('virtualization.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - role: Annotated['DeviceRoleFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - role_id: ID | None = strawberry_django.filter_field() - primary_ip4: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - primary_ip4_id: ID | None = strawberry_django.filter_field() - primary_ip6: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - primary_ip6_id: ID | None = strawberry_django.filter_field() - vcpus: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - memory: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - disk: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - serial: FilterLookup[str] | None = strawberry_django.filter_field() - interface_count: FilterLookup[int] | None = strawberry_django.filter_field() - virtual_disk_count: FilterLookup[int] | None = strawberry_django.filter_field() - interfaces: Annotated['VMInterfaceFilterV1', strawberry.lazy('virtualization.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - services: Annotated['ServiceFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - virtual_disks: Annotated['VirtualDiskFilterV1', strawberry.lazy('virtualization.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.VMInterface, lookups=True) -class VMInterfaceFilterV1(VMComponentFilterMixinV1, InterfaceBaseFilterMixinV1): - ip_addresses: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - vrf: Annotated['VRFFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = strawberry_django.filter_field() - vrf_id: ID | None = strawberry_django.filter_field() - parent: Annotated['VMInterfaceFilterV1', strawberry.lazy('virtualization.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - parent_id: ID | None = strawberry_django.filter_field() - fhrp_group_assignments: Annotated[ - 'FHRPGroupAssignmentFilterV1', strawberry.lazy('ipam.graphql.filters_v1') - ] | None = ( - strawberry_django.filter_field() - ) - tunnel_terminations: Annotated['TunnelTerminationFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - l2vpn_terminations: Annotated['L2VPNFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - mac_addresses: Annotated['MACAddressFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.VirtualDisk, lookups=True) -class VirtualDiskFilterV1(VMComponentFilterMixinV1): - size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) diff --git a/netbox/virtualization/graphql/schema_v1.py b/netbox/virtualization/graphql/schema_v1.py deleted file mode 100644 index 85994411f..000000000 --- a/netbox/virtualization/graphql/schema_v1.py +++ /dev/null @@ -1,27 +0,0 @@ -from typing import List - -import strawberry -import strawberry_django - -from .types_v1 import * - - -@strawberry.type(name="Query") -class VirtualizationQueryV1: - cluster: ClusterTypeV1 = strawberry_django.field() - cluster_list: List[ClusterTypeV1] = strawberry_django.field() - - cluster_group: ClusterGroupTypeV1 = strawberry_django.field() - cluster_group_list: List[ClusterGroupTypeV1] = strawberry_django.field() - - cluster_type: ClusterTypeTypeV1 = strawberry_django.field() - cluster_type_list: List[ClusterTypeTypeV1] = strawberry_django.field() - - virtual_machine: VirtualMachineTypeV1 = strawberry_django.field() - virtual_machine_list: List[VirtualMachineTypeV1] = strawberry_django.field() - - vm_interface: VMInterfaceTypeV1 = strawberry_django.field() - vm_interface_list: List[VMInterfaceTypeV1] = strawberry_django.field() - - virtual_disk: VirtualDiskTypeV1 = strawberry_django.field() - virtual_disk_list: List[VirtualDiskTypeV1] = strawberry_django.field() diff --git a/netbox/virtualization/graphql/types_v1.py b/netbox/virtualization/graphql/types_v1.py deleted file mode 100644 index ba6add3cb..000000000 --- a/netbox/virtualization/graphql/types_v1.py +++ /dev/null @@ -1,147 +0,0 @@ -from typing import Annotated, List, TYPE_CHECKING, Union - -import strawberry -import strawberry_django - -from extras.graphql.mixins_v1 import ConfigContextMixinV1, ContactsMixinV1 -from ipam.graphql.mixins_v1 import IPAddressesMixinV1, VLANGroupsMixinV1 -from netbox.graphql.scalars import BigInt -from netbox.graphql.types_v1 import OrganizationalObjectTypeV1, NetBoxObjectTypeV1, PrimaryObjectTypeV1 -from users.graphql.mixins_v1 import OwnerMixinV1 -from virtualization import models -from .filters_v1 import * - -if TYPE_CHECKING: - from dcim.graphql.types_v1 import ( - DeviceRoleTypeV1, - DeviceTypeV1, - LocationTypeV1, - MACAddressTypeV1, - PlatformTypeV1, - RegionTypeV1, - SiteGroupTypeV1, - SiteTypeV1, - ) - from extras.graphql.types_v1 import ConfigTemplateTypeV1 - from ipam.graphql.types_v1 import IPAddressTypeV1, ServiceTypeV1, VLANTranslationPolicyTypeV1, VLANTypeV1, VRFTypeV1 - from tenancy.graphql.types_v1 import TenantTypeV1 - -__all__ = ( - 'ClusterTypeV1', - 'ClusterGroupTypeV1', - 'ClusterTypeTypeV1', - 'VirtualDiskTypeV1', - 'VirtualMachineTypeV1', - 'VMInterfaceTypeV1', -) - - -@strawberry.type -class ComponentTypeV1(OwnerMixinV1, NetBoxObjectTypeV1): - """ - Base type for device/VM components - """ - virtual_machine: Annotated["VirtualMachineTypeV1", strawberry.lazy('virtualization.graphql.types_v1')] - - -@strawberry_django.type( - models.Cluster, - exclude=['scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'], - filters=ClusterFilterV1, - pagination=True -) -class ClusterTypeV1(ContactsMixinV1, VLANGroupsMixinV1, PrimaryObjectTypeV1): - type: Annotated["ClusterTypeTypeV1", strawberry.lazy('virtualization.graphql.types_v1')] | None - group: Annotated["ClusterGroupTypeV1", strawberry.lazy('virtualization.graphql.types_v1')] | None - tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None - virtual_machines: List[Annotated["VirtualMachineTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] - devices: List[Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - @strawberry_django.field - def scope(self) -> Annotated[Union[ - Annotated["LocationTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["RegionTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["SiteGroupTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["SiteTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - ], strawberry.union("ClusterScopeTypeV1")] | None: - return self.scope - - -@strawberry_django.type( - models.ClusterGroup, - fields='__all__', - filters=ClusterGroupFilterV1, - pagination=True -) -class ClusterGroupTypeV1(ContactsMixinV1, VLANGroupsMixinV1, OrganizationalObjectTypeV1): - - clusters: List[Annotated["ClusterTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] - - -@strawberry_django.type( - models.ClusterType, - fields='__all__', - filters=ClusterTypeFilterV1, - pagination=True -) -class ClusterTypeTypeV1(OrganizationalObjectTypeV1): - - clusters: List[ClusterTypeV1] - - -@strawberry_django.type( - models.VirtualMachine, - fields='__all__', - filters=VirtualMachineFilterV1, - pagination=True -) -class VirtualMachineTypeV1(ConfigContextMixinV1, ContactsMixinV1, PrimaryObjectTypeV1): - interface_count: BigInt - virtual_disk_count: BigInt - interface_count: BigInt - config_template: Annotated["ConfigTemplateTypeV1", strawberry.lazy('extras.graphql.types_v1')] | None - site: Annotated["SiteTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - cluster: Annotated["ClusterTypeV1", strawberry.lazy('virtualization.graphql.types_v1')] | None - device: Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None - platform: Annotated["PlatformTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - role: Annotated["DeviceRoleTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - primary_ip4: Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - primary_ip6: Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - - interfaces: List[Annotated["VMInterfaceTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] - services: List[Annotated["ServiceTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] - virtualdisks: List[Annotated["VirtualDiskTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] - - -@strawberry_django.type( - models.VMInterface, - fields='__all__', - filters=VMInterfaceFilterV1, - pagination=True -) -class VMInterfaceTypeV1(IPAddressesMixinV1, ComponentTypeV1): - _name: str - mac_address: str | None - parent: Annotated["VMInterfaceTypeV1", strawberry.lazy('virtualization.graphql.types_v1')] | None - bridge: Annotated["VMInterfaceTypeV1", strawberry.lazy('virtualization.graphql.types_v1')] | None - untagged_vlan: Annotated["VLANTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - vrf: Annotated["VRFTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - primary_mac_address: Annotated["MACAddressTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - qinq_svlan: Annotated["VLANTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - vlan_translation_policy: Annotated["VLANTranslationPolicyTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - - tagged_vlans: List[Annotated["VLANTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] - bridge_interfaces: List[Annotated["VMInterfaceTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] - child_interfaces: List[Annotated["VMInterfaceTypeV1", strawberry.lazy('virtualization.graphql.types_v1')]] - mac_addresses: List[Annotated["MACAddressTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - -@strawberry_django.type( - models.VirtualDisk, - fields='__all__', - filters=VirtualDiskFilterV1, - pagination=True -) -class VirtualDiskTypeV1(ComponentTypeV1): - pass diff --git a/netbox/vpn/graphql/filters_v1.py b/netbox/vpn/graphql/filters_v1.py deleted file mode 100644 index 9b70b9a9e..000000000 --- a/netbox/vpn/graphql/filters_v1.py +++ /dev/null @@ -1,192 +0,0 @@ -from typing import Annotated, TYPE_CHECKING - -import strawberry -import strawberry_django -from strawberry.scalars import ID -from strawberry_django import FilterLookup - -from core.graphql.filter_mixins_v1 import BaseObjectTypeFilterMixinV1, ChangeLogFilterMixinV1 -from extras.graphql.filter_mixins_v1 import CustomFieldsFilterMixinV1, TagsFilterMixinV1 -from netbox.graphql.filter_mixins_v1 import ( - NetBoxModelFilterMixinV1, OrganizationalModelFilterMixinV1, PrimaryModelFilterMixinV1 -) -from tenancy.graphql.filter_mixins_v1 import ContactFilterMixinV1, TenancyFilterMixinV1 -from vpn import models - -if TYPE_CHECKING: - from core.graphql.filters_v1 import ContentTypeFilterV1 - from ipam.graphql.filters_v1 import IPAddressFilterV1, RouteTargetFilterV1 - from netbox.graphql.filter_lookups import IntegerLookup - from .enums import * - -__all__ = ( - 'TunnelGroupFilterV1', - 'TunnelTerminationFilterV1', - 'TunnelFilterV1', - 'IKEProposalFilterV1', - 'IKEPolicyFilterV1', - 'IPSecProposalFilterV1', - 'IPSecPolicyFilterV1', - 'IPSecProfileFilterV1', - 'L2VPNFilterV1', - 'L2VPNTerminationFilterV1', -) - - -@strawberry_django.filter_type(models.TunnelGroup, lookups=True) -class TunnelGroupFilterV1(OrganizationalModelFilterMixinV1): - pass - - -@strawberry_django.filter_type(models.TunnelTermination, lookups=True) -class TunnelTerminationFilterV1( - BaseObjectTypeFilterMixinV1, CustomFieldsFilterMixinV1, TagsFilterMixinV1, ChangeLogFilterMixinV1 -): - tunnel: Annotated['TunnelFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - tunnel_id: ID | None = strawberry_django.filter_field() - role: Annotated['TunnelTerminationRoleEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - termination_type: Annotated['TunnelTerminationTypeEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - termination_type_id: ID | None = strawberry_django.filter_field() - termination_id: ID | None = strawberry_django.filter_field() - outside_ip: Annotated['IPAddressFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - outside_ip_id: ID | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.Tunnel, lookups=True) -class TunnelFilterV1(TenancyFilterMixinV1, PrimaryModelFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - status: Annotated['TunnelStatusEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - group: Annotated['TunnelGroupFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - group_id: ID | None = strawberry_django.filter_field() - encapsulation: Annotated['TunnelEncapsulationEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - ipsec_profile: Annotated['IPSecProfileFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - tunnel_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - terminations: Annotated['TunnelTerminationFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.IKEProposal, lookups=True) -class IKEProposalFilterV1(PrimaryModelFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - authentication_method: Annotated['AuthenticationMethodEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - encryption_algorithm: Annotated['EncryptionAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - authentication_algorithm: Annotated['AuthenticationAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - group: Annotated['DHGroupEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() - sa_lifetime: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - ike_policies: Annotated['IKEPolicyFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.IKEPolicy, lookups=True) -class IKEPolicyFilterV1(PrimaryModelFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - version: Annotated['IKEVersionEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() - mode: Annotated['IKEModeEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() - proposals: Annotated['IKEProposalFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - preshared_key: FilterLookup[str] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.IPSecProposal, lookups=True) -class IPSecProposalFilterV1(PrimaryModelFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - encryption_algorithm: Annotated['EncryptionAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - authentication_algorithm: Annotated['AuthenticationAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - sa_lifetime_seconds: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - sa_lifetime_data: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - ipsec_policies: Annotated['IPSecPolicyFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.IPSecPolicy, lookups=True) -class IPSecPolicyFilterV1(PrimaryModelFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - proposals: Annotated['IPSecProposalFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - pfs_group: Annotated['DHGroupEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.IPSecProfile, lookups=True) -class IPSecProfileFilterV1(PrimaryModelFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - mode: Annotated['IPSecModeEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() - ike_policy: Annotated['IKEPolicyFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - ike_policy_id: ID | None = strawberry_django.filter_field() - ipsec_policy: Annotated['IPSecPolicyFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - ipsec_policy_id: ID | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.L2VPN, lookups=True) -class L2VPNFilterV1(ContactFilterMixinV1, TenancyFilterMixinV1, PrimaryModelFilterMixinV1): - name: FilterLookup[str] | None = strawberry_django.filter_field() - slug: FilterLookup[str] | None = strawberry_django.filter_field() - type: Annotated['L2VPNTypeEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() - identifier: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) - import_targets: Annotated['RouteTargetFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - export_targets: Annotated['RouteTargetFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - terminations: Annotated['L2VPNTerminationFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - - -@strawberry_django.filter_type(models.L2VPNTermination, lookups=True) -class L2VPNTerminationFilterV1(NetBoxModelFilterMixinV1): - l2vpn: Annotated['L2VPNFilterV1', strawberry.lazy('vpn.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - l2vpn_id: ID | None = strawberry_django.filter_field() - assigned_object_type: Annotated['ContentTypeFilterV1', strawberry.lazy('core.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - assigned_object_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( - strawberry_django.filter_field() - ) diff --git a/netbox/vpn/graphql/schema_v1.py b/netbox/vpn/graphql/schema_v1.py deleted file mode 100644 index 7ed22333c..000000000 --- a/netbox/vpn/graphql/schema_v1.py +++ /dev/null @@ -1,39 +0,0 @@ -from typing import List - -import strawberry -import strawberry_django - -from .types_v1 import * - - -@strawberry.type(name="Query") -class VPNQueryV1: - ike_policy: IKEPolicyTypeV1 = strawberry_django.field() - ike_policy_list: List[IKEPolicyTypeV1] = strawberry_django.field() - - ike_proposal: IKEProposalTypeV1 = strawberry_django.field() - ike_proposal_list: List[IKEProposalTypeV1] = strawberry_django.field() - - ipsec_policy: IPSecPolicyTypeV1 = strawberry_django.field() - ipsec_policy_list: List[IPSecPolicyTypeV1] = strawberry_django.field() - - ipsec_profile: IPSecProfileTypeV1 = strawberry_django.field() - ipsec_profile_list: List[IPSecProfileTypeV1] = strawberry_django.field() - - ipsec_proposal: IPSecProposalTypeV1 = strawberry_django.field() - ipsec_proposal_list: List[IPSecProposalTypeV1] = strawberry_django.field() - - l2vpn: L2VPNTypeV1 = strawberry_django.field() - l2vpn_list: List[L2VPNTypeV1] = strawberry_django.field() - - l2vpn_termination: L2VPNTerminationTypeV1 = strawberry_django.field() - l2vpn_termination_list: List[L2VPNTerminationTypeV1] = strawberry_django.field() - - tunnel: TunnelTypeV1 = strawberry_django.field() - tunnel_list: List[TunnelTypeV1] = strawberry_django.field() - - tunnel_group: TunnelGroupTypeV1 = strawberry_django.field() - tunnel_group_list: List[TunnelGroupTypeV1] = strawberry_django.field() - - tunnel_termination: TunnelTerminationTypeV1 = strawberry_django.field() - tunnel_termination_list: List[TunnelTerminationTypeV1] = strawberry_django.field() diff --git a/netbox/vpn/graphql/types_v1.py b/netbox/vpn/graphql/types_v1.py deleted file mode 100644 index 6872cf6e2..000000000 --- a/netbox/vpn/graphql/types_v1.py +++ /dev/null @@ -1,156 +0,0 @@ -from typing import Annotated, List, TYPE_CHECKING, Union - -import strawberry -import strawberry_django - -from extras.graphql.mixins_v1 import ContactsMixinV1, CustomFieldsMixinV1, TagsMixinV1 -from netbox.graphql.types_v1 import ObjectTypeV1, OrganizationalObjectTypeV1, NetBoxObjectTypeV1, PrimaryObjectTypeV1 -from vpn import models -from .filters_v1 import * - -if TYPE_CHECKING: - from dcim.graphql.types_v1 import InterfaceTypeV1 - from ipam.graphql.types_v1 import IPAddressTypeV1, RouteTargetTypeV1, VLANTypeV1 - from netbox.graphql.types_v1 import ContentTypeTypeV1 - from tenancy.graphql.types_v1 import TenantTypeV1 - from virtualization.graphql.types_v1 import VMInterfaceTypeV1 - -__all__ = ( - 'IKEPolicyTypeV1', - 'IKEProposalTypeV1', - 'IPSecPolicyTypeV1', - 'IPSecProfileTypeV1', - 'IPSecProposalTypeV1', - 'L2VPNTypeV1', - 'L2VPNTerminationTypeV1', - 'TunnelGroupTypeV1', - 'TunnelTerminationTypeV1', - 'TunnelTypeV1', -) - - -@strawberry_django.type( - models.TunnelGroup, - fields='__all__', - filters=TunnelGroupFilterV1, - pagination=True -) -class TunnelGroupTypeV1(ContactsMixinV1, OrganizationalObjectTypeV1): - - tunnels: List[Annotated["TunnelTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] - - -@strawberry_django.type( - models.TunnelTermination, - fields='__all__', - filters=TunnelTerminationFilterV1, - pagination=True -) -class TunnelTerminationTypeV1(CustomFieldsMixinV1, TagsMixinV1, ObjectTypeV1): - tunnel: Annotated["TunnelTypeV1", strawberry.lazy('vpn.graphql.types_v1')] - termination_type: Annotated["ContentTypeTypeV1", strawberry.lazy('netbox.graphql.types_v1')] | None - outside_ip: Annotated["IPAddressTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - - -@strawberry_django.type( - models.Tunnel, - fields='__all__', - filters=TunnelFilterV1, - pagination=True -) -class TunnelTypeV1(ContactsMixinV1, PrimaryObjectTypeV1): - group: Annotated["TunnelGroupTypeV1", strawberry.lazy('vpn.graphql.types_v1')] | None - ipsec_profile: Annotated["IPSecProfileTypeV1", strawberry.lazy('vpn.graphql.types_v1')] | None - tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None - - terminations: List[Annotated["TunnelTerminationTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] - - -@strawberry_django.type( - models.IKEProposal, - fields='__all__', - filters=IKEProposalFilterV1, - pagination=True -) -class IKEProposalTypeV1(PrimaryObjectTypeV1): - ike_policies: List[Annotated["IKEPolicyTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] - - -@strawberry_django.type( - models.IKEPolicy, - fields='__all__', - filters=IKEPolicyFilterV1, - pagination=True -) -class IKEPolicyTypeV1(OrganizationalObjectTypeV1): - - proposals: List[Annotated["IKEProposalTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] - ipsec_profiles: List[Annotated["IPSecProfileTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] - - -@strawberry_django.type( - models.IPSecProposal, - fields='__all__', - filters=IPSecProposalFilterV1, - pagination=True -) -class IPSecProposalTypeV1(PrimaryObjectTypeV1): - - ipsec_policies: List[Annotated["IPSecPolicyTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] - - -@strawberry_django.type( - models.IPSecPolicy, - fields='__all__', - filters=IPSecPolicyFilterV1, - pagination=True -) -class IPSecPolicyTypeV1(OrganizationalObjectTypeV1): - - proposals: List[Annotated["IPSecProposalTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] - ipsec_profiles: List[Annotated["IPSecProfileTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] - - -@strawberry_django.type( - models.IPSecProfile, - fields='__all__', - filters=IPSecProfileFilterV1, - pagination=True -) -class IPSecProfileTypeV1(OrganizationalObjectTypeV1): - ike_policy: Annotated["IKEPolicyTypeV1", strawberry.lazy('vpn.graphql.types_v1')] - ipsec_policy: Annotated["IPSecPolicyTypeV1", strawberry.lazy('vpn.graphql.types_v1')] - - tunnels: List[Annotated["TunnelTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] - - -@strawberry_django.type( - models.L2VPN, - fields='__all__', - filters=L2VPNFilterV1, - pagination=True -) -class L2VPNTypeV1(ContactsMixinV1, PrimaryObjectTypeV1): - tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None - - export_targets: List[Annotated["RouteTargetTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] - terminations: List[Annotated["L2VPNTerminationTypeV1", strawberry.lazy('vpn.graphql.types_v1')]] - import_targets: List[Annotated["RouteTargetTypeV1", strawberry.lazy('ipam.graphql.types_v1')]] - - -@strawberry_django.type( - models.L2VPNTermination, - exclude=['assigned_object_type', 'assigned_object_id'], - filters=L2VPNTerminationFilterV1, - pagination=True -) -class L2VPNTerminationTypeV1(NetBoxObjectTypeV1): - l2vpn: Annotated["L2VPNTypeV1", strawberry.lazy('vpn.graphql.types_v1')] - - @strawberry_django.field - def assigned_object(self) -> Annotated[Union[ - Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["VLANTypeV1", strawberry.lazy('ipam.graphql.types_v1')], - Annotated["VMInterfaceTypeV1", strawberry.lazy('virtualization.graphql.types_v1')], - ], strawberry.union("L2VPNAssignmentTypeV1")]: - return self.assigned_object diff --git a/netbox/wireless/graphql/filter_mixins_v1.py b/netbox/wireless/graphql/filter_mixins_v1.py deleted file mode 100644 index 2e32f2cde..000000000 --- a/netbox/wireless/graphql/filter_mixins_v1.py +++ /dev/null @@ -1,26 +0,0 @@ -from dataclasses import dataclass -from typing import Annotated, TYPE_CHECKING - -import strawberry -import strawberry_django -from strawberry_django import FilterLookup - -from core.graphql.filter_mixins_v1 import BaseFilterMixinV1 - -if TYPE_CHECKING: - from .enums import * - -__all__ = ( - 'WirelessAuthenticationBaseFilterMixinV1', -) - - -@dataclass -class WirelessAuthenticationBaseFilterMixinV1(BaseFilterMixinV1): - auth_type: Annotated['WirelessAuthTypeEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - auth_cipher: Annotated['WirelessAuthCipherEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - auth_psk: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/wireless/graphql/filters_v1.py b/netbox/wireless/graphql/filters_v1.py deleted file mode 100644 index 166679843..000000000 --- a/netbox/wireless/graphql/filters_v1.py +++ /dev/null @@ -1,72 +0,0 @@ -from typing import Annotated, TYPE_CHECKING - -import strawberry -import strawberry_django -from strawberry.scalars import ID -from strawberry_django import FilterLookup - -from dcim.graphql.filter_mixins_v1 import ScopedFilterMixinV1 -from netbox.graphql.filter_mixins_v1 import ( - DistanceFilterMixinV1, PrimaryModelFilterMixinV1, NestedGroupModelFilterMixinV1 -) -from tenancy.graphql.filter_mixins_v1 import TenancyFilterMixinV1 -from wireless import models -from .filter_mixins_v1 import WirelessAuthenticationBaseFilterMixinV1 - -if TYPE_CHECKING: - from dcim.graphql.filters_v1 import InterfaceFilterV1 - from ipam.graphql.filters_v1 import VLANFilterV1 - from .enums import * - -__all__ = ( - 'WirelessLANGroupFilterV1', - 'WirelessLANFilterV1', - 'WirelessLinkFilterV1', -) - - -@strawberry_django.filter_type(models.WirelessLANGroup, lookups=True) -class WirelessLANGroupFilterV1(NestedGroupModelFilterMixinV1): - pass - - -@strawberry_django.filter_type(models.WirelessLAN, lookups=True) -class WirelessLANFilterV1( - WirelessAuthenticationBaseFilterMixinV1, - ScopedFilterMixinV1, - TenancyFilterMixinV1, - PrimaryModelFilterMixinV1 -): - ssid: FilterLookup[str] | None = strawberry_django.filter_field() - status: Annotated['WirelessLANStatusEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) - group: Annotated['WirelessLANGroupFilterV1', strawberry.lazy('wireless.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - group_id: ID | None = strawberry_django.filter_field() - vlan: Annotated['VLANFilterV1', strawberry.lazy('ipam.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - vlan_id: ID | None = strawberry_django.filter_field() - - -@strawberry_django.filter_type(models.WirelessLink, lookups=True) -class WirelessLinkFilterV1( - WirelessAuthenticationBaseFilterMixinV1, - DistanceFilterMixinV1, - TenancyFilterMixinV1, - PrimaryModelFilterMixinV1 -): - interface_a: Annotated['InterfaceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - interface_a_id: ID | None = strawberry_django.filter_field() - interface_b: Annotated['InterfaceFilterV1', strawberry.lazy('dcim.graphql.filters_v1')] | None = ( - strawberry_django.filter_field() - ) - interface_b_id: ID | None = strawberry_django.filter_field() - ssid: FilterLookup[str] | None = strawberry_django.filter_field() - status: Annotated['WirelessLANStatusEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( - strawberry_django.filter_field() - ) diff --git a/netbox/wireless/graphql/schema_v1.py b/netbox/wireless/graphql/schema_v1.py deleted file mode 100644 index fafe28c08..000000000 --- a/netbox/wireless/graphql/schema_v1.py +++ /dev/null @@ -1,18 +0,0 @@ -from typing import List - -import strawberry -import strawberry_django - -from .types_v1 import * - - -@strawberry.type(name="Query") -class WirelessQueryV1: - wireless_lan: WirelessLANTypeV1 = strawberry_django.field() - wireless_lan_list: List[WirelessLANTypeV1] = strawberry_django.field() - - wireless_lan_group: WirelessLANGroupTypeV1 = strawberry_django.field() - wireless_lan_group_list: List[WirelessLANGroupTypeV1] = strawberry_django.field() - - wireless_link: WirelessLinkTypeV1 = strawberry_django.field() - wireless_link_list: List[WirelessLinkTypeV1] = strawberry_django.field() diff --git a/netbox/wireless/graphql/types_v1.py b/netbox/wireless/graphql/types_v1.py deleted file mode 100644 index 2c0fbe12f..000000000 --- a/netbox/wireless/graphql/types_v1.py +++ /dev/null @@ -1,71 +0,0 @@ -from typing import Annotated, List, TYPE_CHECKING, Union - -import strawberry -import strawberry_django - -from netbox.graphql.types_v1 import PrimaryObjectTypeV1, NestedGroupObjectTypeV1 -from wireless import models -from .filters_v1 import * - -if TYPE_CHECKING: - from dcim.graphql.types_v1 import ( - DeviceTypeV1, InterfaceTypeV1, LocationTypeV1, RegionTypeV1, SiteGroupTypeV1, SiteTypeV1 - ) - from ipam.graphql.types_v1 import VLANTypeV1 - from tenancy.graphql.types_v1 import TenantTypeV1 - -__all__ = ( - 'WirelessLANTypeV1', - 'WirelessLANGroupTypeV1', - 'WirelessLinkTypeV1', -) - - -@strawberry_django.type( - models.WirelessLANGroup, - fields='__all__', - filters=WirelessLANGroupFilterV1, - pagination=True -) -class WirelessLANGroupTypeV1(NestedGroupObjectTypeV1): - parent: Annotated["WirelessLANGroupTypeV1", strawberry.lazy('wireless.graphql.types_v1')] | None - - wireless_lans: List[Annotated["WirelessLANTypeV1", strawberry.lazy('wireless.graphql.types_v1')]] - children: List[Annotated["WirelessLANGroupTypeV1", strawberry.lazy('wireless.graphql.types_v1')]] - - -@strawberry_django.type( - models.WirelessLAN, - exclude=['scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'], - filters=WirelessLANFilterV1, - pagination=True -) -class WirelessLANTypeV1(PrimaryObjectTypeV1): - group: Annotated["WirelessLANGroupTypeV1", strawberry.lazy('wireless.graphql.types_v1')] | None - vlan: Annotated["VLANTypeV1", strawberry.lazy('ipam.graphql.types_v1')] | None - tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None - - interfaces: List[Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')]] - - @strawberry_django.field - def scope(self) -> Annotated[Union[ - Annotated["LocationTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["RegionTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["SiteGroupTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - Annotated["SiteTypeV1", strawberry.lazy('dcim.graphql.types_v1')], - ], strawberry.union("WirelessLANScopeTypeV1")] | None: - return self.scope - - -@strawberry_django.type( - models.WirelessLink, - fields='__all__', - filters=WirelessLinkFilterV1, - pagination=True -) -class WirelessLinkTypeV1(PrimaryObjectTypeV1): - interface_a: Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] - interface_b: Annotated["InterfaceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] - tenant: Annotated["TenantTypeV1", strawberry.lazy('tenancy.graphql.types_v1')] | None - _interface_a_device: Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None - _interface_b_device: Annotated["DeviceTypeV1", strawberry.lazy('dcim.graphql.types_v1')] | None From 38b2839a1e185d7fbdba66ae9d5bf69c8235ee36 Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Wed, 19 Nov 2025 10:32:11 -0500 Subject: [PATCH 091/125] Remove version-specific unit tests --- netbox/netbox/tests/test_graphql.py | 50 ----------------------------- 1 file changed, 50 deletions(-) diff --git a/netbox/netbox/tests/test_graphql.py b/netbox/netbox/tests/test_graphql.py index f5d69b03e..ca231526f 100644 --- a/netbox/netbox/tests/test_graphql.py +++ b/netbox/netbox/tests/test_graphql.py @@ -1,15 +1,12 @@ import json -import strawberry from django.test import override_settings from django.urls import reverse from rest_framework import status -from strawberry.types.lazy_type import LazyType from core.models import ObjectType from dcim.choices import LocationStatusChoices from dcim.models import Site, Location -from netbox.graphql.schema import QueryV1, QueryV2 from users.models import ObjectPermission from utilities.testing import disable_warnings, APITestCase, TestCase @@ -48,53 +45,6 @@ class GraphQLTestCase(TestCase): class GraphQLAPITestCase(APITestCase): - def test_versioned_types(self): - """ - Check that the GraphQL types defined for each version of the schema (V1 and V2) are correct. - """ - schemas = ( - (1, QueryV1), - (2, QueryV2), - ) - - def _get_class_name(field): - try: - if type(field.type) is strawberry.types.base.StrawberryList: - # Skip scalars - if field.type.of_type in (str, int): - return - if type(field.type.of_type) is LazyType: - return field.type.of_type.type_name - return field.type.of_type.__name__ - if hasattr(field.type, 'name'): - return field.type.__name__ - except AttributeError: - # Unknown field type - return - - def _check_version(class_name, version): - if version == 1: - self.assertTrue(class_name.endswith('V1'), f"{class_name} (v1) is not a V1 type") - elif version == 2: - self.assertFalse(class_name.endswith('V1'), f"{class_name} (v2) is a V1 type") - - for version, query in schemas: - schema = strawberry.Schema(query=query) - query_type = schema.get_type_by_name(query.__name__) - - # Iterate through root fields - for field in query_type.fields: - # Check for V1 suffix on class names - if type_class := _get_class_name(field): - _check_version(type_class, version) - - # Iterate through nested fields - subquery_type = schema.get_type_by_name(type_class) - for subfield in subquery_type.fields: - # Check for V1 suffix on class names - if type_class := _get_class_name(subfield): - _check_version(type_class, version) - @override_settings(LOGIN_REQUIRED=True) def test_graphql_filter_objects(self): """ From 45fc354d45156d94040e987dfda0519a3128b643 Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Wed, 19 Nov 2025 18:25:00 -0500 Subject: [PATCH 092/125] Fix unit tests --- netbox/netbox/tests/test_graphql.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/netbox/netbox/tests/test_graphql.py b/netbox/netbox/tests/test_graphql.py index ca231526f..9dfc5d5df 100644 --- a/netbox/netbox/tests/test_graphql.py +++ b/netbox/netbox/tests/test_graphql.py @@ -96,11 +96,25 @@ class GraphQLAPITestCase(APITestCase): self.assertEqual(len(data['data']['location_list']), 1) self.assertIsNotNone(data['data']['location_list'][0]['site']) - # Test OR logic + # Test OR and exact logic query = """{ location_list( filters: { - status: STATUS_PLANNED, - OR: {status: STATUS_STAGING} + status: {exact: STATUS_PLANNED}, + OR: {status: {exact: STATUS_STAGING}} + }) { + id site {id} + } + }""" + response = self.client.post(url, data={'query': query}, format="json", **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + data = json.loads(response.content) + self.assertNotIn('errors', data) + self.assertEqual(len(data['data']['location_list']), 2) + + # Test in_list logic + query = """{ + location_list( filters: { + status: {in_list: [STATUS_PLANNED, STATUS_STAGING]} }) { id site {id} } From 5143003c684cfe8aedbd4cbaab5f207a8bbfb178 Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Fri, 21 Nov 2025 10:36:54 -0500 Subject: [PATCH 093/125] Add filters for missing fields and for enums in filter_mixins files --- netbox/core/graphql/enums.py | 11 +++++++++++ netbox/core/graphql/filter_mixins.py | 1 + netbox/core/graphql/filters.py | 11 ++++++++--- netbox/dcim/graphql/enums.py | 4 ++++ netbox/dcim/graphql/filter_mixins.py | 21 ++++++++++++++------- netbox/dcim/graphql/filters.py | 7 ++++++- netbox/netbox/graphql/filter_mixins.py | 6 +++--- netbox/virtualization/graphql/enums.py | 2 ++ netbox/virtualization/graphql/filters.py | 5 +++++ netbox/vpn/graphql/enums.py | 2 ++ netbox/vpn/graphql/filters.py | 3 +++ 11 files changed, 59 insertions(+), 14 deletions(-) create mode 100644 netbox/core/graphql/enums.py diff --git a/netbox/core/graphql/enums.py b/netbox/core/graphql/enums.py new file mode 100644 index 000000000..863382258 --- /dev/null +++ b/netbox/core/graphql/enums.py @@ -0,0 +1,11 @@ +import strawberry + +from core.choices import * + +__all__ = ( + 'DataSourceStatusEnum', + 'ObjectChangeActionEnum', +) + +DataSourceStatusEnum = strawberry.enum(DataSourceStatusChoices.as_enum(prefix='status')) +ObjectChangeActionEnum = strawberry.enum(ObjectChangeActionChoices.as_enum(prefix='action')) diff --git a/netbox/core/graphql/filter_mixins.py b/netbox/core/graphql/filter_mixins.py index 27d950d73..4d1f4fad5 100644 --- a/netbox/core/graphql/filter_mixins.py +++ b/netbox/core/graphql/filter_mixins.py @@ -29,6 +29,7 @@ class BaseObjectTypeFilterMixin(BaseFilterMixin): @dataclass class ChangeLogFilterMixin(BaseFilterMixin): id: FilterLookup[ID] | None = strawberry_django.filter_field() + # TODO: "changelog" is not a valid field name; needs to be updated for ObjectChange changelog: Annotated['ObjectChangeFilter', strawberry.lazy('core.graphql.filters')] | None = ( strawberry_django.filter_field() ) diff --git a/netbox/core/graphql/filters.py b/netbox/core/graphql/filters.py index 76ace2362..db42d4d59 100644 --- a/netbox/core/graphql/filters.py +++ b/netbox/core/graphql/filters.py @@ -5,11 +5,12 @@ import strawberry import strawberry_django from django.contrib.contenttypes.models import ContentType as DjangoContentType from strawberry.scalars import ID -from strawberry_django import DatetimeFilterLookup, FilterLookup +from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLookup from core import models from core.graphql.filter_mixins import BaseFilterMixin from netbox.graphql.filter_mixins import PrimaryModelFilterMixin +from .enums import * if TYPE_CHECKING: from netbox.graphql.filter_lookups import IntegerLookup, JSONFilter @@ -44,7 +45,9 @@ class DataSourceFilter(PrimaryModelFilterMixin): name: FilterLookup[str] | None = strawberry_django.filter_field() type: FilterLookup[str] | None = strawberry_django.filter_field() source_url: FilterLookup[str] | None = strawberry_django.filter_field() - status: FilterLookup[str] | None = strawberry_django.filter_field() + status: ( + BaseFilterLookup[Annotated['DataSourceStatusEnum', strawberry.lazy('core.graphql.enums')]] | None + ) = strawberry_django.filter_field() enabled: FilterLookup[bool] | None = strawberry_django.filter_field() ignore_rules: FilterLookup[str] | None = strawberry_django.filter_field() parameters: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( @@ -63,7 +66,9 @@ class ObjectChangeFilter(BaseFilterMixin): user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() user_name: FilterLookup[str] | None = strawberry_django.filter_field() request_id: FilterLookup[str] | None = strawberry_django.filter_field() - action: FilterLookup[str] | None = strawberry_django.filter_field() + action: ( + BaseFilterLookup[Annotated['ObjectChangeActionEnum', strawberry.lazy('core.graphql.enums')]] | None + ) = strawberry_django.filter_field() changed_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( strawberry_django.filter_field() ) diff --git a/netbox/dcim/graphql/enums.py b/netbox/dcim/graphql/enums.py index 62a666b45..437094d72 100644 --- a/netbox/dcim/graphql/enums.py +++ b/netbox/dcim/graphql/enums.py @@ -28,11 +28,13 @@ __all__ = ( 'PowerFeedSupplyEnum', 'PowerFeedTypeEnum', 'PowerOutletFeedLegEnum', + 'PowerOutletStatusEnum', 'PowerOutletTypeEnum', 'PowerPortTypeEnum', 'RackAirflowEnum', 'RackDimensionUnitEnum', 'RackFormFactorEnum', + 'RackReservationStatusEnum', 'RackStatusEnum', 'RackWidthEnum', 'SiteStatusEnum', @@ -65,11 +67,13 @@ PowerFeedStatusEnum = strawberry.enum(PowerFeedStatusChoices.as_enum(prefix='sta PowerFeedSupplyEnum = strawberry.enum(PowerFeedSupplyChoices.as_enum(prefix='supply')) PowerFeedTypeEnum = strawberry.enum(PowerFeedTypeChoices.as_enum(prefix='type')) PowerOutletFeedLegEnum = strawberry.enum(PowerOutletFeedLegChoices.as_enum(prefix='feed_leg')) +PowerOutletStatusEnum = strawberry.enum(PowerOutletStatusChoices.as_enum(prefix='status')) PowerOutletTypeEnum = strawberry.enum(PowerOutletTypeChoices.as_enum(prefix='type')) PowerPortTypeEnum = strawberry.enum(PowerPortTypeChoices.as_enum(prefix='type')) RackAirflowEnum = strawberry.enum(RackAirflowChoices.as_enum()) RackDimensionUnitEnum = strawberry.enum(RackDimensionUnitChoices.as_enum(prefix='unit')) RackFormFactorEnum = strawberry.enum(RackFormFactorChoices.as_enum(prefix='type')) +RackReservationStatusEnum = strawberry.enum(RackReservationStatusChoices.as_enum(prefix='status')) RackStatusEnum = strawberry.enum(RackStatusChoices.as_enum(prefix='status')) RackWidthEnum = strawberry.enum(RackWidthChoices.as_enum(prefix='width')) SiteStatusEnum = strawberry.enum(SiteStatusChoices.as_enum(prefix='status')) diff --git a/netbox/dcim/graphql/filter_mixins.py b/netbox/dcim/graphql/filter_mixins.py index 25379ad7f..c05eec1f5 100644 --- a/netbox/dcim/graphql/filter_mixins.py +++ b/netbox/dcim/graphql/filter_mixins.py @@ -4,7 +4,7 @@ from typing import Annotated, TYPE_CHECKING import strawberry import strawberry_django from strawberry import ID -from strawberry_django import FilterLookup +from strawberry_django import BaseFilterLookup, FilterLookup from core.graphql.filter_mixins import BaseFilterMixin, ChangeLogFilterMixin from core.graphql.filters import ContentTypeFilter @@ -60,7 +60,9 @@ class ModularComponentModelFilterMixin(ComponentModelFilterMixin): class CabledObjectModelFilterMixin(BaseFilterMixin): cable: Annotated['CableFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() cable_id: ID | None = strawberry_django.filter_field() - cable_end: CableEndEnum | None = strawberry_django.filter_field() + cable_end: ( + BaseFilterLookup[Annotated['CableEndEnum', strawberry.lazy('dcim.graphql.enums')]] | None + ) = strawberry_django.filter_field() mark_connected: FilterLookup[bool] | None = strawberry_django.filter_field() @@ -96,7 +98,9 @@ class InterfaceBaseFilterMixin(BaseFilterMixin): mtu: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() ) - mode: InterfaceModeEnum | None = strawberry_django.filter_field() + mode: ( + BaseFilterLookup[Annotated['InterfaceModeEnum', strawberry.lazy('dcim.graphql.enums')]] | None + ) = strawberry_django.filter_field() bridge: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() ) @@ -110,8 +114,9 @@ class InterfaceBaseFilterMixin(BaseFilterMixin): qinq_svlan: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( strawberry_django.filter_field() ) - vlan_translation_policy: Annotated['VLANTranslationPolicyFilter', strawberry.lazy('ipam.graphql.filters')] | None \ - = strawberry_django.filter_field() + vlan_translation_policy: ( + Annotated['VLANTranslationPolicyFilter', strawberry.lazy('ipam.graphql.filters')] | None + ) = strawberry_django.filter_field() primary_mac_address: Annotated['MACAddressFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() ) @@ -120,7 +125,9 @@ class InterfaceBaseFilterMixin(BaseFilterMixin): @dataclass class RackBaseFilterMixin(WeightFilterMixin, PrimaryModelFilterMixin): - width: Annotated['RackWidthEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + width: BaseFilterLookup[Annotated['RackWidthEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) u_height: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() ) @@ -137,7 +144,7 @@ class RackBaseFilterMixin(WeightFilterMixin, PrimaryModelFilterMixin): outer_depth: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() ) - outer_unit: Annotated['RackDimensionUnitEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + outer_unit: BaseFilterLookup[Annotated['RackDimensionUnitEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) mounting_depth: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py index de156b55b..a6d076410 100644 --- a/netbox/dcim/graphql/filters.py +++ b/netbox/dcim/graphql/filters.py @@ -1,6 +1,5 @@ from typing import Annotated, TYPE_CHECKING -from django.db.models import Q import strawberry import strawberry_django from strawberry.scalars import ID @@ -810,6 +809,9 @@ class PowerOutletFilter(ModularComponentModelFilterMixin, CabledObjectModelFilte color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( strawberry_django.filter_field() ) + status: BaseFilterLookup[Annotated['PowerOutletStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter_type(models.PowerOutletTemplate, lookups=True) @@ -924,6 +926,9 @@ class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilterMixin): user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() user_id: ID | None = strawberry_django.filter_field() description: FilterLookup[str] | None = strawberry_django.filter_field() + status: BaseFilterLookup[Annotated['RackReservationStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter_type(models.RackRole, lookups=True) diff --git a/netbox/netbox/graphql/filter_mixins.py b/netbox/netbox/graphql/filter_mixins.py index b77238325..cb9fcffe7 100644 --- a/netbox/netbox/graphql/filter_mixins.py +++ b/netbox/netbox/graphql/filter_mixins.py @@ -5,7 +5,7 @@ from typing import TypeVar, TYPE_CHECKING, Annotated import strawberry import strawberry_django from strawberry import ID -from strawberry_django import FilterLookup, DatetimeFilterLookup +from strawberry_django import BaseFilterLookup, FilterLookup, DatetimeFilterLookup from core.graphql.filter_mixins import BaseFilterMixin, BaseObjectTypeFilterMixin, ChangeLogFilterMixin from extras.graphql.filter_mixins import CustomFieldsFilterMixin, JournalEntriesFilterMixin, TagsFilterMixin @@ -76,7 +76,7 @@ class ImageAttachmentFilterMixin(BaseFilterMixin): @dataclass class WeightFilterMixin(BaseFilterMixin): weight: FilterLookup[float] | None = strawberry_django.filter_field() - weight_unit: Annotated['WeightUnitEnum', strawberry.lazy('netbox.graphql.enums')] | None = ( + weight_unit: BaseFilterLookup[Annotated['WeightUnitEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( strawberry_django.filter_field() ) @@ -99,6 +99,6 @@ class SyncedDataFilterMixin(BaseFilterMixin): @dataclass class DistanceFilterMixin(BaseFilterMixin): distance: FilterLookup[float] | None = strawberry_django.filter_field() - distance_unit: Annotated['DistanceUnitEnum', strawberry.lazy('netbox.graphql.enums')] | None = ( + distance_unit: BaseFilterLookup[Annotated['DistanceUnitEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( strawberry_django.filter_field() ) diff --git a/netbox/virtualization/graphql/enums.py b/netbox/virtualization/graphql/enums.py index 3c7ebad8a..7a67e6868 100644 --- a/netbox/virtualization/graphql/enums.py +++ b/netbox/virtualization/graphql/enums.py @@ -5,7 +5,9 @@ from virtualization.choices import * __all__ = ( 'ClusterStatusEnum', 'VirtualMachineStatusEnum', + 'VirtualMachineStatusEnum', ) ClusterStatusEnum = strawberry.enum(ClusterStatusChoices.as_enum(prefix='status')) +VirtualMachineStartOnBootEnum = strawberry.enum(VirtualMachineStartOnBootChoices.as_enum(prefix='start_on_boot')) VirtualMachineStatusEnum = strawberry.enum(VirtualMachineStatusChoices.as_enum(prefix='status')) diff --git a/netbox/virtualization/graphql/filters.py b/netbox/virtualization/graphql/filters.py index 5b29e362a..ad8e40ee5 100644 --- a/netbox/virtualization/graphql/filters.py +++ b/netbox/virtualization/graphql/filters.py @@ -130,6 +130,11 @@ class VirtualMachineFilter( virtual_disks: Annotated['VirtualDiskFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( strawberry_django.filter_field() ) + start_on_boot: ( + BaseFilterLookup[Annotated['VirtualMachineStartOnBootEnum', strawberry.lazy('virtualization.graphql.enums')] + ] | None) = ( + strawberry_django.filter_field() + ) @strawberry_django.filter_type(models.VMInterface, lookups=True) diff --git a/netbox/vpn/graphql/enums.py b/netbox/vpn/graphql/enums.py index 518528b83..5007386c2 100644 --- a/netbox/vpn/graphql/enums.py +++ b/netbox/vpn/graphql/enums.py @@ -10,6 +10,7 @@ __all__ = ( 'IKEModeEnum', 'IKEVersionEnum', 'IPSecModeEnum', + 'L2VPNStatusEnum', 'L2VPNTypeEnum', 'TunnelEncapsulationEnum', 'TunnelStatusEnum', @@ -24,6 +25,7 @@ EncryptionAlgorithmEnum = strawberry.enum(EncryptionAlgorithmChoices.as_enum(pre IKEModeEnum = strawberry.enum(IKEModeChoices.as_enum()) IKEVersionEnum = strawberry.enum(IKEVersionChoices.as_enum(prefix='version')) IPSecModeEnum = strawberry.enum(IPSecModeChoices.as_enum()) +L2VPNStatusEnum = strawberry.enum(L2VPNStatusChoices.as_enum(prefix='status')) L2VPNTypeEnum = strawberry.enum(L2VPNTypeChoices.as_enum(prefix='type')) TunnelEncapsulationEnum = strawberry.enum(TunnelEncapsulationChoices.as_enum(prefix='encap')) TunnelStatusEnum = strawberry.enum(TunnelStatusChoices.as_enum(prefix='status')) diff --git a/netbox/vpn/graphql/filters.py b/netbox/vpn/graphql/filters.py index 7955550c9..403ece3b3 100644 --- a/netbox/vpn/graphql/filters.py +++ b/netbox/vpn/graphql/filters.py @@ -200,6 +200,9 @@ class L2VPNFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixi terminations: Annotated['L2VPNTerminationFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( strawberry_django.filter_field() ) + status: BaseFilterLookup[Annotated['L2VPNStatusEnum', strawberry.lazy('vpn.graphql.enums')]] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter_type(models.L2VPNTermination, lookups=True) From 1dcfc05c32bba7b77ccbec716700e21ae1810bff Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Fri, 21 Nov 2025 14:59:27 -0500 Subject: [PATCH 094/125] Add import Q back in --- netbox/dcim/graphql/filters.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py index a6d076410..1c99beedb 100644 --- a/netbox/dcim/graphql/filters.py +++ b/netbox/dcim/graphql/filters.py @@ -1,5 +1,6 @@ from typing import Annotated, TYPE_CHECKING +from django.db.models import Q import strawberry import strawberry_django from strawberry.scalars import ID From ae21a6a6847a418b6efa82c14adace5b925712be Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Tue, 25 Nov 2025 13:06:24 -0500 Subject: [PATCH 095/125] Change explicitly specified id fields to FilterLookups --- netbox/core/graphql/filters.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/core/graphql/filters.py b/netbox/core/graphql/filters.py index db42d4d59..b61917c7e 100644 --- a/netbox/core/graphql/filters.py +++ b/netbox/core/graphql/filters.py @@ -26,7 +26,7 @@ __all__ = ( @strawberry_django.filter_type(models.DataFile, lookups=True) class DataFileFilter(BaseFilterMixin): - id: ID | None = strawberry_django.filter_field() + id: FilterLookup[ID] | None = strawberry_django.filter_field() created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() last_updated: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() source: Annotated['DataSourceFilter', strawberry.lazy('core.graphql.filters')] | None = ( @@ -61,7 +61,7 @@ class DataSourceFilter(PrimaryModelFilterMixin): @strawberry_django.filter_type(models.ObjectChange, lookups=True) class ObjectChangeFilter(BaseFilterMixin): - id: ID | None = strawberry_django.filter_field() + id: FilterLookup[ID] | None = strawberry_django.filter_field() time: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() user_name: FilterLookup[str] | None = strawberry_django.filter_field() @@ -89,6 +89,6 @@ class ObjectChangeFilter(BaseFilterMixin): @strawberry_django.filter_type(DjangoContentType, lookups=True) class ContentTypeFilter(BaseFilterMixin): - id: ID | None = strawberry_django.filter_field() + id: FilterLookup[ID] | None = strawberry_django.filter_field() app_label: FilterLookup[str] | None = strawberry_django.filter_field() model: FilterLookup[str] | None = strawberry_django.filter_field() From 7cc7c7ab81e9950ebe769371e1b9a67c86517de5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 25 Nov 2025 13:18:15 -0500 Subject: [PATCH 096/125] Closes #20788: Cable profiles and and position mapping (#20802) --- docs/models/dcim/cable.md | 15 + netbox/circuits/filtersets.py | 2 +- .../migrations/0054_cable_position.py | 23 + netbox/dcim/api/serializers_/cables.py | 13 +- netbox/dcim/cable_profiles.py | 108 +++ netbox/dcim/choices.py | 13 + netbox/dcim/constants.py | 8 + netbox/dcim/filtersets.py | 17 +- netbox/dcim/forms/bulk_edit.py | 10 +- netbox/dcim/forms/bulk_import.py | 10 +- netbox/dcim/forms/filtersets.py | 7 +- netbox/dcim/forms/model_forms.py | 4 +- .../dcim/management/commands/trace_paths.py | 4 +- netbox/dcim/migrations/0219_cable_profile.py | 40 + netbox/dcim/migrations/0220_cable_position.py | 107 +++ netbox/dcim/models/cables.py | 90 +- netbox/dcim/models/device_components.py | 26 +- netbox/dcim/signals.py | 4 +- netbox/dcim/tables/cables.py | 5 +- netbox/dcim/tests/test_api.py | 5 +- netbox/dcim/tests/test_cablepaths.py | 136 ++- netbox/dcim/tests/test_cablepaths2.py | 788 ++++++++++++++++++ netbox/dcim/tests/utils.py | 88 ++ netbox/dcim/utils.py | 19 +- netbox/project-static/dist/netbox.js | 2 +- netbox/project-static/dist/netbox.js.map | 4 +- netbox/project-static/src/select/config.ts | 5 + netbox/templates/dcim/cable.html | 4 + netbox/templates/dcim/htmx/cable_edit.html | 1 + netbox/wireless/signals.py | 4 +- 30 files changed, 1418 insertions(+), 144 deletions(-) create mode 100644 netbox/circuits/migrations/0054_cable_position.py create mode 100644 netbox/dcim/cable_profiles.py create mode 100644 netbox/dcim/migrations/0219_cable_profile.py create mode 100644 netbox/dcim/migrations/0220_cable_position.py create mode 100644 netbox/dcim/tests/test_cablepaths2.py create mode 100644 netbox/dcim/tests/utils.py diff --git a/docs/models/dcim/cable.md b/docs/models/dcim/cable.md index 20f6c03c7..8a60d8353 100644 --- a/docs/models/dcim/cable.md +++ b/docs/models/dcim/cable.md @@ -21,6 +21,21 @@ The cable's operational status. Choices include: * Planned * Decommissioning +### Profile + +!!! note "This field was introduced in NetBox v4.5." + +The profile to which the cable conforms. The profile determines the mapping of termination between the two ends and enables logical tracing across complex connections, such as breakout cables. Supported profiles are listed below. + +* Straight (single position) +* Straight (multi-position) +* Shuffle (2x2 MPO8) +* Shuffle (4x4 MPO8) + +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. + ### Type The cable's physical medium or classification. diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index 9d95037ec..77f713899 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -346,7 +346,7 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet): model = CircuitTermination fields = ( 'id', 'termination_id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description', - 'mark_connected', 'pp_info', 'cable_end', + 'mark_connected', 'pp_info', 'cable_end', 'cable_position', ) def search(self, queryset, name, value): diff --git a/netbox/circuits/migrations/0054_cable_position.py b/netbox/circuits/migrations/0054_cable_position.py new file mode 100644 index 000000000..cedc8813b --- /dev/null +++ b/netbox/circuits/migrations/0054_cable_position.py @@ -0,0 +1,23 @@ +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('circuits', '0053_owner'), + ] + + operations = [ + migrations.AddField( + model_name='circuittermination', + name='cable_position', + field=models.PositiveIntegerField( + blank=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(1024), + ], + ), + ), + ] diff --git a/netbox/dcim/api/serializers_/cables.py b/netbox/dcim/api/serializers_/cables.py index 5f3017368..0effbd536 100644 --- a/netbox/dcim/api/serializers_/cables.py +++ b/netbox/dcim/api/serializers_/cables.py @@ -25,15 +25,16 @@ class CableSerializer(PrimaryModelSerializer): a_terminations = GenericObjectSerializer(many=True, required=False) b_terminations = GenericObjectSerializer(many=True, required=False) status = ChoiceField(choices=LinkStatusChoices, required=False) + profile = ChoiceField(choices=CableProfileChoices, required=False) tenant = TenantSerializer(nested=True, required=False, allow_null=True) length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False, allow_null=True) class Meta: model = Cable fields = [ - 'id', 'url', 'display_url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant', - 'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags', 'custom_fields', - 'created', 'last_updated', + 'id', 'url', 'display_url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'profile', + 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'label', 'description') @@ -60,10 +61,12 @@ class CableTerminationSerializer(NetBoxModelSerializer): model = CableTermination fields = [ 'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id', - 'termination', 'created', 'last_updated', + 'termination', 'position', 'created', 'last_updated', ] read_only_fields = fields - brief_fields = ('id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id') + brief_fields = ( + 'id', 'url', 'display', 'cable', 'cable_end', 'position', 'termination_type', 'termination_id', + ) class CablePathSerializer(serializers.ModelSerializer): diff --git a/netbox/dcim/cable_profiles.py b/netbox/dcim/cable_profiles.py new file mode 100644 index 000000000..4251cd4d9 --- /dev/null +++ b/netbox/dcim/cable_profiles.py @@ -0,0 +1,108 @@ +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + +from dcim.models import CableTermination + + +class BaseCableProfile: + # Maximum number of terminations allowed per side + a_max_connections = None + b_max_connections = None + + def clean(self, cable): + # Enforce maximum connection limits + if self.a_max_connections and len(cable.a_terminations) > self.a_max_connections: + raise ValidationError({ + 'a_terminations': _( + 'Maximum A side connections for profile {profile}: {max}' + ).format( + profile=cable.get_profile_display(), + max=self.a_max_connections, + ) + }) + if self.b_max_connections and len(cable.b_terminations) > self.b_max_connections: + raise ValidationError({ + 'b_terminations': _( + 'Maximum B side connections for profile {profile}: {max}' + ).format( + profile=cable.get_profile_display(), + max=self.b_max_connections, + ) + }) + + def get_mapped_position(self, side, position): + """ + Return the mapped position for a given cable end and position. + + By default, assume all positions are symmetrical. + """ + return position + + def get_peer_terminations(self, terminations, position_stack): + local_end = terminations[0].cable_end + qs = CableTermination.objects.filter( + cable=terminations[0].cable, + cable_end=terminations[0].opposite_cable_end + ) + + # TODO: Optimize this to use a single query under any condition + if position_stack: + # Attempt to find a peer termination at the same position currently in the stack. Pop the stack only if + # we find one. Otherwise, return any peer terminations with a null position. + position = self.get_mapped_position(local_end, position_stack[-1][0]) + if peers := qs.filter(position=position): + position_stack.pop() + return peers + + return qs.filter(position=None) + + +class StraightSingleCableProfile(BaseCableProfile): + a_max_connections = 1 + b_max_connections = 1 + + +class StraightMultiCableProfile(BaseCableProfile): + a_max_connections = None + b_max_connections = None + + +class Shuffle2x2MPO8CableProfile(BaseCableProfile): + a_max_connections = 8 + b_max_connections = 8 + _mapping = { + 1: 1, + 2: 2, + 3: 5, + 4: 6, + 5: 3, + 6: 4, + 7: 7, + 8: 8, + } + + def get_mapped_position(self, side, position): + return self._mapping.get(position) + + +class Shuffle4x4MPO8CableProfile(BaseCableProfile): + a_max_connections = 8 + b_max_connections = 8 + # A side to B side position mapping + _a_mapping = { + 1: 1, + 2: 3, + 3: 5, + 4: 7, + 5: 2, + 6: 4, + 7: 6, + 8: 8, + } + # B side to A side position mapping (reverse of _a_mapping) + _b_mapping = {v: k for k, v in _a_mapping.items()} + + def get_mapped_position(self, side, position): + if side.lower() == 'b': + return self._b_mapping.get(position) + return self._a_mapping.get(position) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index cca2dd0bb..0656d96aa 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1717,6 +1717,19 @@ class PortTypeChoices(ChoiceSet): # Cables/links # +class CableProfileChoices(ChoiceSet): + STRAIGHT_SINGLE = 'straight-single' + STRAIGHT_MULTI = 'straight-multi' + SHUFFLE_2X2_MPO8 = 'shuffle-2x2-mpo8' + SHUFFLE_4X4_MPO8 = 'shuffle-4x4-mpo8' + + CHOICES = ( + (STRAIGHT_SINGLE, _('Straight (single position)')), + (STRAIGHT_MULTI, _('Straight (multi-position)')), + (SHUFFLE_2X2_MPO8, _('Shuffle (2x2 MPO8)')), + (SHUFFLE_4X4_MPO8, _('Shuffle (4x4 MPO8)')), + ) + class CableTypeChoices(ChoiceSet): # Copper - Twisted Pair (UTP/STP) diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 387b4d6a7..5e4311c13 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -20,6 +20,14 @@ RACK_ELEVATION_DEFAULT_MARGIN_WIDTH = 15 RACK_STARTING_UNIT_DEFAULT = 1 +# +# Cables +# + +CABLE_POSITION_MIN = 1 +CABLE_POSITION_MAX = 1024 + + # # RearPorts # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 0fd7631ac..9c161aa54 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1699,7 +1699,7 @@ class ConsolePortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSe class Meta: model = ConsolePort - fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end') + fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end', 'cable_position') class ConsoleServerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet, PathEndpointFilterSet): @@ -1710,7 +1710,7 @@ class ConsoleServerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFi class Meta: model = ConsoleServerPort - fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end') + fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end', 'cable_position') class PowerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet, PathEndpointFilterSet): @@ -1723,6 +1723,7 @@ class PowerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet, model = PowerPort fields = ( 'id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable_end', + 'cable_position', ) @@ -1748,6 +1749,7 @@ class PowerOutletFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSe model = PowerOutlet fields = ( 'id', 'name', 'status', 'label', 'feed_leg', 'description', 'color', 'mark_connected', 'cable_end', + 'cable_position', ) @@ -2055,7 +2057,7 @@ class InterfaceFilterSet( fields = ( 'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', - 'cable_id', 'cable_end', + 'cable_id', 'cable_end', 'cable_position', ) def filter_virtual_chassis_member_or_master(self, queryset, name, value): @@ -2107,6 +2109,7 @@ class FrontPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet) model = FrontPort fields = ( 'id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description', 'mark_connected', 'cable_end', + 'cable_position', ) @@ -2120,6 +2123,7 @@ class RearPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet): model = RearPort fields = ( 'id', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable_end', + 'cable_position', ) @@ -2316,6 +2320,9 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet): status = django_filters.MultipleChoiceFilter( choices=LinkStatusChoices ) + profile = django_filters.MultipleChoiceFilter( + choices=CableProfileChoices + ) color = django_filters.MultipleChoiceFilter( choices=ColorChoices ) @@ -2465,7 +2472,7 @@ class CableTerminationFilterSet(ChangeLoggedModelFilterSet): class Meta: model = CableTermination - fields = ('id', 'cable', 'cable_end', 'termination_type', 'termination_id') + fields = ('id', 'cable', 'cable_end', 'position', 'termination_type', 'termination_id') class PowerPanelFilterSet(PrimaryModelFilterSet, ContactModelFilterSet): @@ -2582,7 +2589,7 @@ class PowerFeedFilterSet(PrimaryModelFilterSet, CabledObjectFilterSet, PathEndpo model = PowerFeed fields = ( 'id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', - 'available_power', 'mark_connected', 'cable_end', 'description', + 'available_power', 'mark_connected', 'cable_end', 'cable_position', 'description', ) def search(self, queryset, name, value): diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 6d1e4d7cc..9aa076a6a 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -780,6 +780,12 @@ class CableBulkEditForm(PrimaryModelBulkEditForm): required=False, initial='' ) + profile = forms.ChoiceField( + label=_('Profile'), + choices=add_blank_choice(CableProfileChoices), + required=False, + initial='' + ) tenant = DynamicModelChoiceField( label=_('Tenant'), queryset=Tenant.objects.all(), @@ -808,11 +814,11 @@ class CableBulkEditForm(PrimaryModelBulkEditForm): model = Cable fieldsets = ( - FieldSet('type', 'status', 'tenant', 'label', 'description'), + FieldSet('type', 'status', 'profile', 'tenant', 'label', 'description'), FieldSet('color', 'length', 'length_unit', name=_('Attributes')), ) nullable_fields = ( - 'type', 'status', 'tenant', 'label', 'color', 'length', 'description', 'comments', + 'type', 'status', 'profile', 'tenant', 'label', 'color', 'length', 'description', 'comments', ) diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 127511779..ba0b44b0d 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -1461,6 +1461,12 @@ class CableImportForm(PrimaryModelImportForm): required=False, help_text=_('Connection status') ) + profile = CSVChoiceField( + label=_('Profile'), + choices=CableProfileChoices, + required=False, + help_text=_('Cable connection profile') + ) type = CSVChoiceField( label=_('Type'), choices=CableTypeChoices, @@ -1491,8 +1497,8 @@ class CableImportForm(PrimaryModelImportForm): model = Cable fields = [ 'side_a_site', 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_site', 'side_b_device', 'side_b_type', - 'side_b_name', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', - 'owner', 'comments', 'tags', + 'side_b_name', 'type', 'status', 'profile', 'tenant', 'label', 'color', 'length', 'length_unit', + 'description', 'owner', 'comments', 'tags', ] def __init__(self, data=None, *args, **kwargs): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 1197002a5..f874ce916 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1119,7 +1119,7 @@ class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm): fieldsets = ( FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')), - FieldSet('type', 'status', 'color', 'length', 'length_unit', 'unterminated', name=_('Attributes')), + FieldSet('type', 'status', 'profile', 'color', 'length', 'length_unit', 'unterminated', name=_('Attributes')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) region_id = DynamicModelMultipleChoiceField( @@ -1175,6 +1175,11 @@ class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm): required=False, choices=add_blank_choice(LinkStatusChoices) ) + profile = forms.MultipleChoiceField( + label=_('Profile'), + required=False, + choices=add_blank_choice(CableProfileChoices) + ) color = ColorField( label=_('Color'), required=False diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index a774bb90f..75a827476 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -807,8 +807,8 @@ class CableForm(TenancyForm, PrimaryModelForm): class Meta: model = Cable fields = [ - 'a_terminations_type', 'b_terminations_type', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', - 'length', 'length_unit', 'description', 'owner', 'comments', 'tags', + 'a_terminations_type', 'b_terminations_type', 'type', 'status', 'profile', 'tenant_group', 'tenant', + 'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags', ] diff --git a/netbox/dcim/management/commands/trace_paths.py b/netbox/dcim/management/commands/trace_paths.py index 592aeb6a7..ded4e1780 100644 --- a/netbox/dcim/management/commands/trace_paths.py +++ b/netbox/dcim/management/commands/trace_paths.py @@ -4,7 +4,7 @@ from django.db import connection from django.db.models import Q from dcim.models import CablePath, ConsolePort, ConsoleServerPort, Interface, PowerFeed, PowerOutlet, PowerPort -from dcim.signals import create_cablepath +from dcim.signals import create_cablepaths ENDPOINT_MODELS = ( ConsolePort, @@ -81,7 +81,7 @@ class Command(BaseCommand): self.stdout.write(f'Retracing {origins_count} cabled {model._meta.verbose_name_plural}...') i = 0 for i, obj in enumerate(origins, start=1): - create_cablepath([obj]) + create_cablepaths([obj]) if not i % 100: self.draw_progress_bar(i * 100 / origins_count) self.draw_progress_bar(100) diff --git a/netbox/dcim/migrations/0219_cable_profile.py b/netbox/dcim/migrations/0219_cable_profile.py new file mode 100644 index 000000000..140ce800b --- /dev/null +++ b/netbox/dcim/migrations/0219_cable_profile.py @@ -0,0 +1,40 @@ +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0218_devicetype_device_count'), + ] + + operations = [ + migrations.AddField( + model_name='cable', + name='profile', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='cabletermination', + name='position', + field=models.PositiveIntegerField( + blank=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(1024), + ], + ), + ), + migrations.AlterModelOptions( + name='cabletermination', + options={'ordering': ('cable', 'cable_end', 'position', 'pk')}, + ), + migrations.AddConstraint( + model_name='cabletermination', + constraint=models.UniqueConstraint( + fields=('cable', 'cable_end', 'position'), + name='dcim_cabletermination_unique_position' + ), + ), + ] diff --git a/netbox/dcim/migrations/0220_cable_position.py b/netbox/dcim/migrations/0220_cable_position.py new file mode 100644 index 000000000..6ee74174b --- /dev/null +++ b/netbox/dcim/migrations/0220_cable_position.py @@ -0,0 +1,107 @@ +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('dcim', '0219_cable_profile'), + ] + + operations = [ + migrations.AddField( + model_name='consoleport', + name='cable_position', + field=models.PositiveIntegerField( + blank=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(1024), + ], + ), + ), + migrations.AddField( + model_name='consoleserverport', + name='cable_position', + field=models.PositiveIntegerField( + blank=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(1024), + ], + ), + ), + migrations.AddField( + model_name='frontport', + name='cable_position', + field=models.PositiveIntegerField( + blank=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(1024), + ], + ), + ), + migrations.AddField( + model_name='interface', + name='cable_position', + field=models.PositiveIntegerField( + blank=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(1024), + ], + ), + ), + migrations.AddField( + model_name='powerfeed', + name='cable_position', + field=models.PositiveIntegerField( + blank=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(1024), + ], + ), + ), + migrations.AddField( + model_name='poweroutlet', + name='cable_position', + field=models.PositiveIntegerField( + blank=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(1024), + ], + ), + ), + migrations.AddField( + model_name='powerport', + name='cable_position', + field=models.PositiveIntegerField( + blank=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(1024), + ], + ), + ), + migrations.AddField( + model_name='rearport', + name='cable_position', + field=models.PositiveIntegerField( + blank=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(1024), + ], + ), + ), + ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 73ea08ff4..94ebc7570 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -3,6 +3,7 @@ import itertools from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.dispatch import Signal from django.utils.translation import gettext_lazy as _ @@ -20,7 +21,7 @@ from utilities.fields import ColorField, GenericArrayForeignKey from utilities.querysets import RestrictedQuerySet from utilities.serialization import deserialize_object, serialize_object from wireless.models import WirelessLink -from .device_components import FrontPort, RearPort, PathEndpoint +from .device_components import FrontPort, PathEndpoint, RearPort __all__ = ( 'Cable', @@ -54,6 +55,12 @@ class Cable(PrimaryModel): choices=LinkStatusChoices, default=LinkStatusChoices.STATUS_CONNECTED ) + profile = models.CharField( + verbose_name=_('profile'), + max_length=50, + choices=CableProfileChoices, + blank=True, + ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, @@ -92,7 +99,7 @@ class Cable(PrimaryModel): null=True ) - clone_fields = ('tenant', 'type',) + clone_fields = ('tenant', 'type', 'profile') class Meta: ordering = ('pk',) @@ -123,6 +130,16 @@ class Cable(PrimaryModel): def get_status_color(self): return LinkStatusChoices.colors.get(self.status) + @property + def profile_class(self): + from dcim import cable_profiles + return { + CableProfileChoices.STRAIGHT_SINGLE: cable_profiles.StraightSingleCableProfile, + CableProfileChoices.STRAIGHT_MULTI: cable_profiles.StraightMultiCableProfile, + CableProfileChoices.SHUFFLE_2X2_MPO8: cable_profiles.Shuffle2x2MPO8CableProfile, + CableProfileChoices.SHUFFLE_4X4_MPO8: cable_profiles.Shuffle4x4MPO8CableProfile, + }.get(self.profile) + def _get_x_terminations(self, side): """ Return the terminating objects for the given cable end (A or B). @@ -195,6 +212,10 @@ class Cable(PrimaryModel): if self._state.adding and self.pk is None and (not self.a_terminations or not self.b_terminations): raise ValidationError(_("Must define A and B terminations when creating a new cable.")) + # Validate terminations against the assigned cable profile (if any) + if self.profile: + self.profile_class().clean(self) + if self._terminations_modified: # Check that all termination objects for either end are of the same type @@ -315,12 +336,14 @@ class Cable(PrimaryModel): ct.delete() # Save any new CableTerminations - for termination in self.a_terminations: + for i, termination in enumerate(self.a_terminations, start=1): if not termination.pk or termination not in a_terminations: - CableTermination(cable=self, cable_end='A', termination=termination).save() - for termination in self.b_terminations: + position = i if self.profile and isinstance(termination, PathEndpoint) else None + CableTermination(cable=self, cable_end='A', position=position, termination=termination).save() + for i, termination in enumerate(self.b_terminations, start=1): if not termination.pk or termination not in b_terminations: - CableTermination(cable=self, cable_end='B', termination=termination).save() + position = i if self.profile and isinstance(termination, PathEndpoint) else None + CableTermination(cable=self, cable_end='B', position=position, termination=termination).save() class CableTermination(ChangeLoggedModel): @@ -347,6 +370,14 @@ class CableTermination(ChangeLoggedModel): ct_field='termination_type', fk_field='termination_id' ) + position = models.PositiveIntegerField( + blank=True, + null=True, + validators=( + MinValueValidator(CABLE_POSITION_MIN), + MaxValueValidator(CABLE_POSITION_MAX) + ) + ) # Cached associations to enable efficient filtering _device = models.ForeignKey( @@ -377,12 +408,16 @@ class CableTermination(ChangeLoggedModel): objects = RestrictedQuerySet.as_manager() class Meta: - ordering = ('cable', 'cable_end', 'pk') + ordering = ('cable', 'cable_end', 'position', 'pk') constraints = ( models.UniqueConstraint( fields=('termination_type', 'termination_id'), name='%(app_label)s_%(class)s_unique_termination' ), + models.UniqueConstraint( + fields=('cable', 'cable_end', 'position'), + name='%(app_label)s_%(class)s_unique_position' + ), ) verbose_name = _('cable termination') verbose_name_plural = _('cable terminations') @@ -446,6 +481,7 @@ class CableTermination(ChangeLoggedModel): termination.snapshot() termination.cable = self.cable termination.cable_end = self.cable_end + termination.cable_position = self.position termination.save() def delete(self, *args, **kwargs): @@ -455,6 +491,7 @@ class CableTermination(ChangeLoggedModel): termination.snapshot() termination.cable = None termination.cable_end = None + termination.cable_position = None termination.save() super().delete(*args, **kwargs) @@ -653,6 +690,9 @@ class CablePath(models.Model): path.append([ object_to_path_node(t) for t in terminations ]) + # If not null, push cable_position onto the stack + if terminations[0].cable_position is not None: + position_stack.append([terminations[0].cable_position]) # Step 2: Determine the attached links (Cable or WirelessLink), if any links = [termination.link for termination in terminations if termination.link is not None] @@ -687,23 +727,31 @@ class CablePath(models.Model): # Step 6: Determine the far-end terminations if isinstance(links[0], Cable): - termination_type = ObjectType.objects.get_for_model(terminations[0]) - local_cable_terminations = CableTermination.objects.filter( - termination_type=termination_type, - termination_id__in=[t.pk for t in terminations] - ) + # Profile-based tracing + if links[0].profile: + cable_profile = links[0].profile_class() + peer_cable_terminations = cable_profile.get_peer_terminations(terminations, position_stack) + remote_terminations = [ct.termination for ct in peer_cable_terminations] - q_filter = Q() - for lct in local_cable_terminations: - cable_end = 'A' if lct.cable_end == 'B' else 'B' - q_filter |= Q(cable=lct.cable, cable_end=cable_end) + # Legacy (positionless) behavior + else: + termination_type = ObjectType.objects.get_for_model(terminations[0]) + local_cable_terminations = CableTermination.objects.filter( + termination_type=termination_type, + termination_id__in=[t.pk for t in terminations] + ) - # Make sure this filter has been populated; if not, we have probably been given invalid data - if not q_filter: - break + q_filter = Q() + for lct in local_cable_terminations: + cable_end = 'A' if lct.cable_end == 'B' else 'B' + q_filter |= Q(cable=lct.cable, cable_end=cable_end) - remote_cable_terminations = CableTermination.objects.filter(q_filter) - remote_terminations = [ct.termination for ct in remote_cable_terminations] + # Make sure this filter has been populated; if not, we have probably been given invalid data + if not q_filter: + break + + remote_cable_terminations = CableTermination.objects.filter(q_filter) + remote_terminations = [ct.termination for ct in remote_cable_terminations] else: # WirelessLink remote_terminations = [ diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 3e801a8e9..8c9acc48f 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -175,6 +175,15 @@ class CabledObjectModel(models.Model): blank=True, null=True ) + cable_position = models.PositiveIntegerField( + verbose_name=_('cable position'), + blank=True, + null=True, + validators=( + MinValueValidator(CABLE_POSITION_MIN), + MaxValueValidator(CABLE_POSITION_MAX) + ), + ) mark_connected = models.BooleanField( verbose_name=_('mark connected'), default=False, @@ -194,14 +203,23 @@ class CabledObjectModel(models.Model): def clean(self): super().clean() - if self.cable and not self.cable_end: - raise ValidationError({ - "cable_end": _("Must specify cable end (A or B) when attaching a cable.") - }) + if self.cable: + if not self.cable_end: + raise ValidationError({ + "cable_end": _("Must specify cable end (A or B) when attaching a cable.") + }) + if not self.cable_position: + raise ValidationError({ + "cable_position": _("Must specify cable termination position when attaching a cable.") + }) if self.cable_end and not self.cable: raise ValidationError({ "cable_end": _("Cable end must not be set without a cable.") }) + if self.cable_position and not self.cable: + raise ValidationError({ + "cable_position": _("Cable termination position must not be set without a cable.") + }) if self.mark_connected and self.cable: raise ValidationError({ "mark_connected": _("Cannot mark as connected with a cable attached.") diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 9295ddbdb..eb1825c1a 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -11,7 +11,7 @@ from .models import ( VirtualChassis, ) from .models.cables import trace_paths -from .utils import create_cablepath, rebuild_paths +from .utils import create_cablepaths, rebuild_paths COMPONENT_MODELS = ( ConsolePort, @@ -114,7 +114,7 @@ def update_connected_endpoints(instance, created, raw=False, **kwargs): if not nodes: continue if isinstance(nodes[0], PathEndpoint): - create_cablepath(nodes) + create_cablepaths(nodes) else: rebuild_paths(nodes) diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py index a4e3be269..72220591e 100644 --- a/netbox/dcim/tables/cables.py +++ b/netbox/dcim/tables/cables.py @@ -108,6 +108,7 @@ class CableTable(TenancyColumnsMixin, PrimaryModelTable): verbose_name=_('Site B') ) status = columns.ChoiceFieldColumn() + profile = columns.ChoiceFieldColumn() length = columns.TemplateColumn( template_code=CABLE_LENGTH, order_by=('_abs_length') @@ -125,8 +126,8 @@ class CableTable(TenancyColumnsMixin, PrimaryModelTable): model = Cable fields = ( 'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b', - 'location_a', 'location_b', 'site_a', 'site_b', 'status', 'type', 'tenant', 'tenant_group', 'color', - 'color_name', 'length', 'description', 'comments', 'tags', 'created', 'last_updated', + 'location_a', 'location_b', 'site_a', 'site_b', 'status', 'profile', 'type', 'tenant', 'tenant_group', + 'color', 'color_name', 'length', 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type', diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 938a625b0..bdade5395 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -2396,6 +2396,7 @@ class CableTest(APIViewTestCases.APIViewTestCase): 'object_id': interfaces[14].pk, }], 'label': 'Cable 4', + 'profile': CableProfileChoices.STRAIGHT_SINGLE, }, { 'a_terminations': [{ @@ -2407,6 +2408,7 @@ class CableTest(APIViewTestCases.APIViewTestCase): 'object_id': interfaces[15].pk, }], 'label': 'Cable 5', + 'profile': CableProfileChoices.STRAIGHT_SINGLE, }, { 'a_terminations': [{ @@ -2418,6 +2420,7 @@ class CableTest(APIViewTestCases.APIViewTestCase): 'object_id': interfaces[16].pk, }], 'label': 'Cable 6', + # No profile (legacy behavior) }, ] @@ -2427,7 +2430,7 @@ class CableTerminationTest( APIViewTestCases.ListObjectsViewTestCase, ): model = CableTermination - brief_fields = ['cable', 'cable_end', 'display', 'id', 'termination_id', 'termination_type', 'url'] + brief_fields = ['cable', 'cable_end', 'display', 'id', 'position', 'termination_id', 'termination_type', 'url'] @classmethod def setUpTestData(cls): diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 399478e70..d3a7cfc5e 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -1,100 +1,21 @@ -from django.test import TestCase - from circuits.models import * from dcim.choices import LinkStatusChoices from dcim.models import * from dcim.svg import CableTraceSVG -from dcim.utils import object_to_path_node +from dcim.tests.utils import CablePathTestCase from utilities.exceptions import AbortRequest -class CablePathTestCase(TestCase): +class LegacyCablePathTests(CablePathTestCase): """ - Test NetBox's ability to trace and retrace CablePaths in response to data model changes. Tests are numbered - as follows: + Test NetBox's ability to trace and retrace CablePaths in response to data model changes, without cable profiles. + Tests are numbered as follows: 1XX: Test direct connections between different endpoint types 2XX: Test different cable topologies 3XX: Test responses to changes in existing objects 4XX: Test to exclude specific cable topologies """ - @classmethod - def setUpTestData(cls): - - # Create a single device that will hold all components - cls.site = Site.objects.create(name='Site', slug='site') - - 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') - cls.device = Device.objects.create(site=cls.site, device_type=device_type, role=role, name='Test Device') - - cls.powerpanel = PowerPanel.objects.create(site=cls.site, name='Power Panel') - - provider = Provider.objects.create(name='Provider', slug='provider') - circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type') - cls.circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1') - - def _get_cablepath(self, nodes, **kwargs): - """ - Return a given cable path - - :param nodes: Iterable of steps, with each step being either a single node or a list of nodes - - :return: The matching CablePath (if any) - """ - path = [] - for step in nodes: - if type(step) in (list, tuple): - path.append([object_to_path_node(node) for node in step]) - else: - path.append([object_to_path_node(step)]) - return CablePath.objects.filter(path=path, **kwargs).first() - - def assertPathExists(self, nodes, **kwargs): - """ - Assert that a CablePath from origin to destination with a specific intermediate path exists. Returns the - first matching CablePath, if found. - - :param nodes: Iterable of steps, with each step being either a single node or a list of nodes - """ - cablepath = self._get_cablepath(nodes, **kwargs) - self.assertIsNotNone(cablepath, msg='CablePath not found') - - return cablepath - - def assertPathDoesNotExist(self, nodes, **kwargs): - """ - Assert that a specific CablePath does *not* exist. - - :param nodes: Iterable of steps, with each step being either a single node or a list of nodes - """ - cablepath = self._get_cablepath(nodes, **kwargs) - self.assertIsNone(cablepath, msg='Unexpected CablePath found') - - def assertPathIsSet(self, origin, cablepath, msg=None): - """ - Assert that a specific CablePath instance is set as the path on the origin. - - :param origin: The originating path endpoint - :param cablepath: The CablePath instance originating from this endpoint - :param msg: Custom failure message (optional) - """ - if msg is None: - msg = f"Path #{cablepath.pk} not set on originating endpoint {origin}" - self.assertEqual(origin._path_id, cablepath.pk, msg=msg) - - def assertPathIsNotSet(self, origin, msg=None): - """ - Assert that a specific CablePath instance is set as the path on the origin. - - :param origin: The originating path endpoint - :param msg: Custom failure message (optional) - """ - if msg is None: - msg = f"Path #{origin._path_id} set as origin on {origin}; should be None!" - self.assertIsNone(origin._path_id, msg=msg) - def test_101_interface_to_interface(self): """ [IF1] --C1-- [IF2] @@ -2270,6 +2191,55 @@ class CablePathTestCase(TestCase): CableTraceSVG(interface1).render() CableTraceSVG(interface2).render() + def test_223_single_path_via_multiple_pass_throughs_with_breakouts(self): + """ + [IF1] --C1-- [FP1] [RP1] --C2-- [IF3] + [IF2] [FP2] [RP2] [IF4] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + interface4 = Interface.objects.create(device=self.device, name='Interface 4') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + ) + frontport2 = FrontPort.objects.create( + device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 + ) + + # Create cables + cable1 = Cable( + a_terminations=[interface1, interface2], + b_terminations=[frontport1, frontport2] + ) + cable1.save() + cable2 = Cable( + a_terminations=[rearport1, rearport2], + b_terminations=[interface3, interface4] + ) + cable2.save() + + # Validate paths + self.assertPathExists( + ( + [interface1, interface2], cable1, [frontport1, frontport2], + [rearport1, rearport2], cable2, [interface3, interface4], + ), + is_complete=True, + is_active=True + ) + self.assertPathExists( + ( + [interface3, interface4], cable2, [rearport1, rearport2], + [frontport1, frontport2], cable1, [interface1, interface2], + ), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 2) + def test_301_create_path_via_existing_cable(self): """ [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] diff --git a/netbox/dcim/tests/test_cablepaths2.py b/netbox/dcim/tests/test_cablepaths2.py new file mode 100644 index 000000000..c7895c6d2 --- /dev/null +++ b/netbox/dcim/tests/test_cablepaths2.py @@ -0,0 +1,788 @@ +from unittest import skipIf + +from circuits.models import CircuitTermination +from dcim.choices import CableProfileChoices +from dcim.models import * +from dcim.svg import CableTraceSVG +from dcim.tests.utils import CablePathTestCase + + +class CablePathTests(CablePathTestCase): + """ + Test the creation of CablePaths for Cables with different profiles applied. + + Tests are numbered as follows: + 1XX: Test direct connections using each profile + 2XX: Topology tests replicated from the legacy test case and adapted to use profiles + """ + + def test_101_cable_profile_straight_single(self): + """ + [IF1] --C1-- [IF2] + + Cable profile: Straight single + """ + interfaces = [ + Interface.objects.create(device=self.device, name='Interface 1'), + Interface.objects.create(device=self.device, name='Interface 2'), + ] + + # Create cable 1 + cable1 = Cable( + profile=CableProfileChoices.STRAIGHT_SINGLE, + a_terminations=[interfaces[0]], + b_terminations=[interfaces[1]], + ) + cable1.clean() + cable1.save() + + path1 = self.assertPathExists( + (interfaces[0], cable1, interfaces[1]), + is_complete=True, + is_active=True + ) + path2 = self.assertPathExists( + (interfaces[1], cable1, interfaces[0]), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 2) + interfaces[0].refresh_from_db() + interfaces[1].refresh_from_db() + self.assertPathIsSet(interfaces[0], path1) + self.assertPathIsSet(interfaces[1], path2) + self.assertEqual(interfaces[0].cable_position, 1) + self.assertEqual(interfaces[1].cable_position, 1) + + # Test SVG generation + CableTraceSVG(interfaces[0]).render() + + # Delete cable 1 + cable1.delete() + + # Check that all CablePaths have been deleted + self.assertEqual(CablePath.objects.count(), 0) + + def test_102_cable_profile_straight_multi(self): + """ + [IF1] --C1-- [IF3] + [IF2] [IF4] + + Cable profile: Straight multi + """ + interfaces = [ + Interface.objects.create(device=self.device, name='Interface 1'), + Interface.objects.create(device=self.device, name='Interface 2'), + Interface.objects.create(device=self.device, name='Interface 3'), + Interface.objects.create(device=self.device, name='Interface 4'), + ] + + # Create cable 1 + cable1 = Cable( + profile=CableProfileChoices.STRAIGHT_MULTI, + a_terminations=[interfaces[0], interfaces[1]], + b_terminations=[interfaces[2], interfaces[3]], + ) + cable1.clean() + cable1.save() + + path1 = self.assertPathExists( + (interfaces[0], cable1, interfaces[2]), + is_complete=True, + is_active=True + ) + path2 = self.assertPathExists( + (interfaces[1], cable1, interfaces[3]), + is_complete=True, + is_active=True + ) + path3 = self.assertPathExists( + (interfaces[2], cable1, interfaces[0]), + is_complete=True, + is_active=True + ) + path4 = self.assertPathExists( + (interfaces[3], cable1, interfaces[1]), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 4) + + for interface in interfaces: + interface.refresh_from_db() + self.assertPathIsSet(interfaces[0], path1) + self.assertPathIsSet(interfaces[1], path2) + self.assertPathIsSet(interfaces[2], path3) + self.assertPathIsSet(interfaces[3], path4) + self.assertEqual(interfaces[0].cable_position, 1) + self.assertEqual(interfaces[1].cable_position, 2) + self.assertEqual(interfaces[2].cable_position, 1) + self.assertEqual(interfaces[3].cable_position, 2) + + # Test SVG generation + CableTraceSVG(interfaces[0]).render() + + # Delete cable 1 + cable1.delete() + + # Check that all CablePaths have been deleted + self.assertEqual(CablePath.objects.count(), 0) + + def test_103_cable_profile_2x2_mpo8(self): + """ + [IF1:1] --C1-- [IF3:1] + [IF1:2] [IF3:2] + [IF1:3] [IF3:3] + [IF1:4] [IF3:4] + [IF2:1] [IF4:1] + [IF2:2] [IF4:2] + [IF2:3] [IF4:3] + [IF2:4] [IF4:4] + + Cable profile: Shuffle (2x2 MPO8) + """ + interfaces = [ + # A side + Interface.objects.create(device=self.device, name='Interface 1:1'), + Interface.objects.create(device=self.device, name='Interface 1:2'), + Interface.objects.create(device=self.device, name='Interface 1:3'), + Interface.objects.create(device=self.device, name='Interface 1:4'), + Interface.objects.create(device=self.device, name='Interface 2:1'), + Interface.objects.create(device=self.device, name='Interface 2:2'), + Interface.objects.create(device=self.device, name='Interface 2:3'), + Interface.objects.create(device=self.device, name='Interface 2:4'), + # B side + Interface.objects.create(device=self.device, name='Interface 3:1'), + Interface.objects.create(device=self.device, name='Interface 3:2'), + Interface.objects.create(device=self.device, name='Interface 3:3'), + Interface.objects.create(device=self.device, name='Interface 3:4'), + Interface.objects.create(device=self.device, name='Interface 4:1'), + Interface.objects.create(device=self.device, name='Interface 4:2'), + Interface.objects.create(device=self.device, name='Interface 4:3'), + Interface.objects.create(device=self.device, name='Interface 4:4'), + ] + + # Create cable 1 + cable1 = Cable( + profile=CableProfileChoices.SHUFFLE_2X2_MPO8, + a_terminations=interfaces[0:8], + b_terminations=interfaces[8:16], + ) + cable1.clean() + cable1.save() + + paths = [ + # A-to-B paths + self.assertPathExists( + (interfaces[0], cable1, interfaces[8]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[1], cable1, interfaces[9]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[2], cable1, interfaces[12]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[3], cable1, interfaces[13]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[4], cable1, interfaces[10]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[5], cable1, interfaces[11]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[6], cable1, interfaces[14]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[7], cable1, interfaces[15]), is_complete=True, is_active=True + ), + # B-to-A paths + self.assertPathExists( + (interfaces[8], cable1, interfaces[0]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[9], cable1, interfaces[1]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[10], cable1, interfaces[4]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[11], cable1, interfaces[5]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[12], cable1, interfaces[2]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[13], cable1, interfaces[3]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[14], cable1, interfaces[6]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[15], cable1, interfaces[7]), is_complete=True, is_active=True + ), + ] + self.assertEqual(CablePath.objects.count(), len(paths)) + + for i, (interface, path) in enumerate(zip(interfaces, paths)): + interface.refresh_from_db() + self.assertPathIsSet(interface, path) + self.assertEqual(interface.cable_end, 'A' if i < 8 else 'B') + self.assertEqual(interface.cable_position, (i % 8) + 1) + + # Test SVG generation + CableTraceSVG(interfaces[0]).render() + + # Delete cable 1 + cable1.delete() + + # Check that all CablePaths have been deleted + self.assertEqual(CablePath.objects.count(), 0) + + def test_104_cable_profile_4x4_mpo8(self): + """ + [IF1:1] --C1-- [IF3:1] + [IF1:2] [IF3:2] + [IF1:3] [IF3:3] + [IF1:4] [IF3:4] + [IF2:1] [IF4:1] + [IF2:2] [IF4:2] + [IF2:3] [IF4:3] + [IF2:4] [IF4:4] + + Cable profile: Shuffle (4x4 MPO8) + """ + interfaces = [ + # A side + Interface.objects.create(device=self.device, name='Interface 1:1'), + Interface.objects.create(device=self.device, name='Interface 1:2'), + Interface.objects.create(device=self.device, name='Interface 2:1'), + Interface.objects.create(device=self.device, name='Interface 2:2'), + Interface.objects.create(device=self.device, name='Interface 3:1'), + Interface.objects.create(device=self.device, name='Interface 3:2'), + Interface.objects.create(device=self.device, name='Interface 4:1'), + Interface.objects.create(device=self.device, name='Interface 4:2'), + # B side + Interface.objects.create(device=self.device, name='Interface 5:1'), + Interface.objects.create(device=self.device, name='Interface 5:2'), + Interface.objects.create(device=self.device, name='Interface 6:1'), + Interface.objects.create(device=self.device, name='Interface 6:2'), + Interface.objects.create(device=self.device, name='Interface 7:1'), + Interface.objects.create(device=self.device, name='Interface 7:2'), + Interface.objects.create(device=self.device, name='Interface 8:1'), + Interface.objects.create(device=self.device, name='Interface 8:2'), + ] + + # Create cable 1 + cable1 = Cable( + profile=CableProfileChoices.SHUFFLE_4X4_MPO8, + a_terminations=interfaces[0:8], + b_terminations=interfaces[8:16], + ) + cable1.clean() + cable1.save() + + paths = [ + # A-to-B paths + self.assertPathExists( + (interfaces[0], cable1, interfaces[8]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[1], cable1, interfaces[10]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[2], cable1, interfaces[12]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[3], cable1, interfaces[14]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[4], cable1, interfaces[9]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[5], cable1, interfaces[11]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[6], cable1, interfaces[13]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[7], cable1, interfaces[15]), is_complete=True, is_active=True + ), + # B-to-A paths + self.assertPathExists( + (interfaces[8], cable1, interfaces[0]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[9], cable1, interfaces[4]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[10], cable1, interfaces[1]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[11], cable1, interfaces[5]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[12], cable1, interfaces[2]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[13], cable1, interfaces[6]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[14], cable1, interfaces[3]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[15], cable1, interfaces[7]), is_complete=True, is_active=True + ), + ] + self.assertEqual(CablePath.objects.count(), len(paths)) + + for i, (interface, path) in enumerate(zip(interfaces, paths)): + interface.refresh_from_db() + self.assertPathIsSet(interface, path) + self.assertEqual(interface.cable_end, 'A' if i < 8 else 'B') + self.assertEqual(interface.cable_position, (i % 8) + 1) + + # Test SVG generation + CableTraceSVG(interfaces[0]).render() + + # Delete cable 1 + cable1.delete() + + # Check that all CablePaths have been deleted + self.assertEqual(CablePath.objects.count(), 0) + + def test_202_single_path_via_pass_through_with_breakouts(self): + """ + [IF1] --C1-- [FP1] [RP1] --C2-- [IF3] + [IF2] [IF4] + """ + interfaces = [ + Interface.objects.create(device=self.device, name='Interface 1'), + Interface.objects.create(device=self.device, name='Interface 2'), + Interface.objects.create(device=self.device, name='Interface 3'), + Interface.objects.create(device=self.device, name='Interface 4'), + ] + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, + name='Front Port 1', + rear_port=rearport1, + rear_port_position=1 + ) + + # Create cables + cable1 = Cable( + profile=CableProfileChoices.STRAIGHT_MULTI, + a_terminations=[interfaces[0], interfaces[1]], + b_terminations=[frontport1], + ) + cable1.clean() + cable1.save() + cable2 = Cable( + profile=CableProfileChoices.STRAIGHT_MULTI, + a_terminations=[rearport1], + b_terminations=[interfaces[2], interfaces[3]] + ) + cable2.clean() + cable2.save() + + paths = [ + self.assertPathExists( + (interfaces[0], cable1, frontport1, rearport1, cable2, interfaces[2]), + is_complete=True, + is_active=True + ), + self.assertPathExists( + (interfaces[1], cable1, frontport1, rearport1, cable2, interfaces[3]), + is_complete=True, + is_active=True + ), + self.assertPathExists( + (interfaces[2], cable2, rearport1, frontport1, cable1, interfaces[0]), + is_complete=True, + is_active=True + ), + self.assertPathExists( + (interfaces[3], cable2, rearport1, frontport1, cable1, interfaces[1]), + is_complete=True, + is_active=True + ), + ] + self.assertEqual(CablePath.objects.count(), 4) + for interface in interfaces: + interface.refresh_from_db() + self.assertPathIsSet(interfaces[0], paths[0]) + self.assertPathIsSet(interfaces[1], paths[1]) + self.assertPathIsSet(interfaces[2], paths[2]) + self.assertPathIsSet(interfaces[3], paths[3]) + + # Test SVG generation + CableTraceSVG(interfaces[0]).render() + + def test_204_multiple_paths_via_pass_through_with_breakouts(self): + """ + [IF1] --C1-- [FP1:1] [RP1] --C3-- [RP2] [FP2:1] --C4-- [IF4] + [IF2] [IF5] + [IF3] --C2-- [FP1:2] [FP2:2] --C5-- [IF6] + [IF4] [IF7] + """ + interfaces = [ + Interface.objects.create(device=self.device, name='Interface 1'), + Interface.objects.create(device=self.device, name='Interface 2'), + Interface.objects.create(device=self.device, name='Interface 3'), + Interface.objects.create(device=self.device, name='Interface 4'), + Interface.objects.create(device=self.device, name='Interface 5'), + Interface.objects.create(device=self.device, name='Interface 6'), + Interface.objects.create(device=self.device, name='Interface 7'), + Interface.objects.create(device=self.device, name='Interface 8'), + ] + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4) + frontport1_1 = FrontPort.objects.create( + device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1 + ) + frontport1_2 = FrontPort.objects.create( + device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2 + ) + frontport2_1 = FrontPort.objects.create( + device=self.device, name='Front Port 2:1', rear_port=rearport2, rear_port_position=1 + ) + frontport2_2 = FrontPort.objects.create( + device=self.device, name='Front Port 2:2', rear_port=rearport2, rear_port_position=2 + ) + + # Create cables + cable1 = Cable( + profile=CableProfileChoices.STRAIGHT_MULTI, + a_terminations=[interfaces[0], interfaces[1]], + b_terminations=[frontport1_1] + ) + cable1.clean() + cable1.save() + cable2 = Cable( + profile=CableProfileChoices.STRAIGHT_MULTI, + a_terminations=[interfaces[2], interfaces[3]], + b_terminations=[frontport1_2] + ) + cable2.clean() + cable2.save() + cable3 = Cable( + profile=CableProfileChoices.STRAIGHT_SINGLE, + a_terminations=[rearport1], + b_terminations=[rearport2] + ) + cable3.clean() + cable3.save() + cable4 = Cable( + profile=CableProfileChoices.STRAIGHT_MULTI, + a_terminations=[frontport2_1], + b_terminations=[interfaces[4], interfaces[5]] + ) + cable4.clean() + cable4.save() + cable5 = Cable( + profile=CableProfileChoices.STRAIGHT_MULTI, + a_terminations=[frontport2_2], + b_terminations=[interfaces[6], interfaces[7]] + ) + cable5.clean() + cable5.save() + + paths = [ + self.assertPathExists( + ( + interfaces[0], cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1, cable4, + interfaces[4], + ), + is_complete=True, + is_active=True, + ), + self.assertPathExists( + ( + interfaces[1], cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1, cable4, + interfaces[5], + ), + is_complete=True, + is_active=True, + ), + self.assertPathExists( + ( + interfaces[2], cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2, cable5, + interfaces[6], + ), + is_complete=True, + is_active=True, + ), + self.assertPathExists( + ( + interfaces[3], cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2, cable5, + interfaces[7], + ), + is_complete=True, + is_active=True, + ), + self.assertPathExists( + ( + interfaces[4], cable4, frontport2_1, rearport2, cable3, rearport1, frontport1_1, cable1, + interfaces[0], + ), + is_complete=True, + is_active=True, + ), + self.assertPathExists( + ( + interfaces[5], cable4, frontport2_1, rearport2, cable3, rearport1, frontport1_1, cable1, + interfaces[1], + ), + is_complete=True, + is_active=True, + ), + self.assertPathExists( + ( + interfaces[6], cable5, frontport2_2, rearport2, cable3, rearport1, frontport1_2, cable2, + interfaces[2], + ), + is_complete=True, + is_active=True, + ), + self.assertPathExists( + ( + interfaces[7], cable5, frontport2_2, rearport2, cable3, rearport1, frontport1_2, cable2, + interfaces[3], + ), + is_complete=True, + is_active=True, + ), + ] + self.assertEqual(CablePath.objects.count(), 8) + + for interface in interfaces: + interface.refresh_from_db() + self.assertPathIsSet(interfaces[0], paths[0]) + self.assertPathIsSet(interfaces[1], paths[1]) + self.assertPathIsSet(interfaces[2], paths[2]) + self.assertPathIsSet(interfaces[3], paths[3]) + self.assertPathIsSet(interfaces[4], paths[4]) + self.assertPathIsSet(interfaces[5], paths[5]) + self.assertPathIsSet(interfaces[6], paths[6]) + self.assertPathIsSet(interfaces[7], paths[7]) + + # Test SVG generation + CableTraceSVG(interfaces[0]).render() + + def test_212_interface_to_interface_via_circuit_with_breakouts(self): + """ + [IF1] --C1-- [CT1] [CT2] --C2-- [IF3] + [IF2] [IF4] + """ + interfaces = [ + Interface.objects.create(device=self.device, name='Interface 1'), + Interface.objects.create(device=self.device, name='Interface 2'), + Interface.objects.create(device=self.device, name='Interface 3'), + Interface.objects.create(device=self.device, name='Interface 4'), + ] + circuittermination1 = CircuitTermination.objects.create( + circuit=self.circuit, + termination=self.site, + term_side='A' + ) + circuittermination2 = CircuitTermination.objects.create( + circuit=self.circuit, + termination=self.site, + term_side='Z' + ) + + # Create cables + cable1 = Cable( + profile=CableProfileChoices.STRAIGHT_MULTI, + a_terminations=[interfaces[0], interfaces[1]], + b_terminations=[circuittermination1] + ) + cable1.clean() + cable1.save() + cable2 = Cable( + profile=CableProfileChoices.STRAIGHT_MULTI, + a_terminations=[circuittermination2], + b_terminations=[interfaces[2], interfaces[3]] + ) + cable2.clean() + cable2.save() + + # Check for two complete paths in either direction + paths = [ + self.assertPathExists( + (interfaces[0], cable1, circuittermination1, circuittermination2, cable2, interfaces[2]), + is_complete=True, + is_active=True, + ), + self.assertPathExists( + (interfaces[1], cable1, circuittermination1, circuittermination2, cable2, interfaces[3]), + is_complete=True, + is_active=True, + ), + self.assertPathExists( + (interfaces[2], cable2, circuittermination2, circuittermination1, cable1, interfaces[0]), + is_complete=True, + is_active=True, + ), + self.assertPathExists( + (interfaces[3], cable2, circuittermination2, circuittermination1, cable1, interfaces[1]), + is_complete=True, + is_active=True, + ), + ] + self.assertEqual(CablePath.objects.count(), 4) + + for interface in interfaces: + interface.refresh_from_db() + self.assertPathIsSet(interfaces[0], paths[0]) + self.assertPathIsSet(interfaces[1], paths[1]) + self.assertPathIsSet(interfaces[2], paths[2]) + self.assertPathIsSet(interfaces[3], paths[3]) + + # Test SVG generation + CableTraceSVG(interfaces[0]).render() + + def test_217_interface_to_interface_via_rear_ports(self): + """ + [IF1] --C1-- [FP1] [RP1] --C2-- [RP3] [FP3] --C3-- [IF2] + [FP2] [RP2] [RP4] [FP4] + """ + interfaces = [ + Interface.objects.create(device=self.device, name='Interface 1'), + Interface.objects.create(device=self.device, name='Interface 2'), + ] + rear_ports = [ + RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1), + RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1), + RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1), + RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1), + ] + front_ports = [ + FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rear_ports[0], rear_port_position=1 + ), + FrontPort.objects.create( + device=self.device, name='Front Port 2', rear_port=rear_ports[1], rear_port_position=1 + ), + FrontPort.objects.create( + device=self.device, name='Front Port 3', rear_port=rear_ports[2], rear_port_position=1 + ), + FrontPort.objects.create( + device=self.device, name='Front Port 4', rear_port=rear_ports[3], rear_port_position=1 + ), + ] + + # Create cables + cable1 = Cable( + profile=CableProfileChoices.STRAIGHT_MULTI, + a_terminations=[interfaces[0]], + b_terminations=[front_ports[0], front_ports[1]] + ) + cable1.clean() + cable1.save() + cable2 = Cable( + a_terminations=[rear_ports[0], rear_ports[1]], + b_terminations=[rear_ports[2], rear_ports[3]] + ) + cable2.clean() + cable2.save() + cable3 = Cable( + profile=CableProfileChoices.STRAIGHT_MULTI, + a_terminations=[interfaces[1]], + b_terminations=[front_ports[2], front_ports[3]] + ) + cable3.clean() + cable3.save() + + # Check for one complete path in either direction + paths = [ + self.assertPathExists( + ( + interfaces[0], cable1, (front_ports[0], front_ports[1]), (rear_ports[0], rear_ports[1]), cable2, + (rear_ports[2], rear_ports[3]), (front_ports[2], front_ports[3]), cable3, interfaces[1] + ), + is_complete=True + ), + self.assertPathExists( + ( + interfaces[1], cable3, (front_ports[2], front_ports[3]), (rear_ports[2], rear_ports[3]), cable2, + (rear_ports[0], rear_ports[1]), (front_ports[0], front_ports[1]), cable1, interfaces[0] + ), + is_complete=True + ), + ] + self.assertEqual(CablePath.objects.count(), 2) + + for interface in interfaces: + interface.refresh_from_db() + self.assertPathIsSet(interfaces[0], paths[0]) + self.assertPathIsSet(interfaces[1], paths[1]) + + # Test SVG generation + CableTraceSVG(interfaces[0]).render() + + # TODO: Revisit this test under FR #20564 + @skipIf(True, "Waiting for FR #20564") + def test_223_single_path_via_multiple_pass_throughs_with_breakouts(self): + """ + [IF1] --C1-- [FP1] [RP1] --C2-- [IF3] + [IF2] [FP2] [RP2] [IF4] + """ + interfaces = [ + Interface.objects.create(device=self.device, name='Interface 1'), + Interface.objects.create(device=self.device, name='Interface 2'), + Interface.objects.create(device=self.device, name='Interface 3'), + Interface.objects.create(device=self.device, name='Interface 4'), + ] + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + ) + frontport2 = FrontPort.objects.create( + device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 + ) + + # Create cables + cable1 = Cable( + profile=CableProfileChoices.STRAIGHT_MULTI, + a_terminations=[interfaces[0], interfaces[1]], + b_terminations=[frontport1, frontport2] + ) + cable1.clean() + cable1.save() + cable2 = Cable( + profile=CableProfileChoices.STRAIGHT_MULTI, + a_terminations=[rearport1, rearport2], + b_terminations=[interfaces[2], interfaces[3]] + ) + cable2.clean() + cable2.save() + + for path in CablePath.objects.all(): + print(f'{path}: {path.path_objects}') + + # Validate paths + self.assertPathExists( + (interfaces[0], cable1, [frontport1, frontport2], [rearport1, rearport2], cable2, interfaces[2]), + is_complete=True, + is_active=True + ) + self.assertPathExists( + (interfaces[1], cable1, [frontport1, frontport2], [rearport1, rearport2], cable2, interfaces[3]), + is_complete=True, + is_active=True + ) + self.assertPathExists( + (interfaces[2], cable2, [rearport1, rearport2], [frontport1, frontport2], cable1, interfaces[0]), + is_complete=True, + is_active=True + ) + self.assertPathExists( + (interfaces[3], cable2, [rearport1, rearport2], [frontport1, frontport2], cable1, interfaces[1]), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 4) diff --git a/netbox/dcim/tests/utils.py b/netbox/dcim/tests/utils.py new file mode 100644 index 000000000..575034201 --- /dev/null +++ b/netbox/dcim/tests/utils.py @@ -0,0 +1,88 @@ +from django.test import TestCase + +from circuits.models import * +from dcim.models import * +from dcim.utils import object_to_path_node + +__all__ = ( + 'CablePathTestCase', +) + + +class CablePathTestCase(TestCase): + """ + Base class for test cases for cable paths. + """ + @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') + provider = Provider.objects.create(name='Provider', slug='provider') + circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type') + + # Create reusable test objects + cls.site = Site.objects.create(name='Site', slug='site') + cls.device = Device.objects.create(site=cls.site, device_type=device_type, role=role, name='Test Device') + cls.powerpanel = PowerPanel.objects.create(site=cls.site, name='Power Panel') + cls.circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1') + + def _get_cablepath(self, nodes, **kwargs): + """ + Return a given cable path + + :param nodes: Iterable of steps, with each step being either a single node or a list of nodes + + :return: The matching CablePath (if any) + """ + path = [] + for step in nodes: + if type(step) in (list, tuple): + path.append([object_to_path_node(node) for node in step]) + else: + path.append([object_to_path_node(step)]) + return CablePath.objects.filter(path=path, **kwargs).first() + + def assertPathExists(self, nodes, **kwargs): + """ + Assert that a CablePath from origin to destination with a specific intermediate path exists. Returns the + first matching CablePath, if found. + + :param nodes: Iterable of steps, with each step being either a single node or a list of nodes + """ + cablepath = self._get_cablepath(nodes, **kwargs) + self.assertIsNotNone(cablepath, msg='CablePath not found') + + return cablepath + + def assertPathDoesNotExist(self, nodes, **kwargs): + """ + Assert that a specific CablePath does *not* exist. + + :param nodes: Iterable of steps, with each step being either a single node or a list of nodes + """ + cablepath = self._get_cablepath(nodes, **kwargs) + self.assertIsNone(cablepath, msg='Unexpected CablePath found') + + def assertPathIsSet(self, origin, cablepath, msg=None): + """ + Assert that a specific CablePath instance is set as the path on the origin. + + :param origin: The originating path endpoint + :param cablepath: The CablePath instance originating from this endpoint + :param msg: Custom failure message (optional) + """ + if msg is None: + msg = f"Path #{cablepath.pk} not set on originating endpoint {origin}" + self.assertEqual(origin._path_id, cablepath.pk, msg=msg) + + def assertPathIsNotSet(self, origin, msg=None): + """ + Assert that a specific CablePath instance is set as the path on the origin. + + :param origin: The originating path endpoint + :param msg: Custom failure message (optional) + """ + if msg is None: + msg = f"Path #{origin._path_id} set as origin on {origin}; should be None!" + self.assertIsNone(origin._path_id, msg=msg) diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index a03790ea2..2380fbd0d 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -1,3 +1,5 @@ +from collections import defaultdict + from django.apps import apps from django.contrib.contenttypes.models import ContentType from django.db import router, transaction @@ -31,17 +33,22 @@ def path_node_to_object(repr): return ct.model_class().objects.filter(pk=object_id).first() -def create_cablepath(terminations): +def create_cablepaths(objects): """ Create CablePaths for all paths originating from the specified set of nodes. - :param terminations: Iterable of CableTermination objects + :param objects: Iterable of cabled objects (e.g. Interfaces) """ from dcim.models import CablePath - cp = CablePath.from_origin(terminations) - if cp: - cp.save() + # Arrange objects by cable position. All objects with a null position are grouped together. + origins = defaultdict(list) + for obj in objects: + origins[obj.cable_position].append(obj) + + for position, objects in origins.items(): + if cp := CablePath.from_origin(objects): + cp.save() def rebuild_paths(terminations): @@ -56,7 +63,7 @@ def rebuild_paths(terminations): with transaction.atomic(using=router.db_for_write(CablePath)): for cp in cable_paths: cp.delete() - create_cablepath(cp.origins) + create_cablepaths(cp.origins) def update_interface_bridges(device, interface_templates, module=None): diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 095469011..54218a290 100644 --- a/netbox/project-static/dist/netbox.js +++ b/netbox/project-static/dist/netbox.js @@ -1,5 +1,5 @@ "use strict";(()=>{var tu=Object.create;var Mi=Object.defineProperty,nu=Object.defineProperties,iu=Object.getOwnPropertyDescriptor,ru=Object.getOwnPropertyDescriptors,ou=Object.getOwnPropertyNames,ps=Object.getOwnPropertySymbols,su=Object.getPrototypeOf,ms=Object.prototype.hasOwnProperty,au=Object.prototype.propertyIsEnumerable;var qr=(n,e,t)=>e in n?Mi(n,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):n[e]=t,O=(n,e)=>{for(var t in e||(e={}))ms.call(e,t)&&qr(n,t,e[t]);if(ps)for(var t of ps(e))au.call(e,t)&&qr(n,t,e[t]);return n},ae=(n,e)=>nu(n,ru(e));var lu=(n,e)=>()=>(e||n((e={exports:{}}).exports,e),e.exports),gs=(n,e)=>{for(var t in e)Mi(n,t,{get:e[t],enumerable:!0})},cu=(n,e,t,i)=>{if(e&&typeof e=="object"||typeof e=="function")for(let r of ou(e))!ms.call(n,r)&&r!==t&&Mi(n,r,{get:()=>e[r],enumerable:!(i=iu(e,r))||i.enumerable});return n};var uu=(n,e,t)=>(t=n!=null?tu(su(n)):{},cu(e||!n||!n.__esModule?Mi(t,"default",{value:n,enumerable:!0}):t,n));var ee=(n,e,t)=>qr(n,typeof e!="symbol"?e+"":e,t);var at=(n,e,t)=>new Promise((i,r)=>{var o=l=>{try{a(t.next(l))}catch(c){r(c)}},s=l=>{try{a(t.throw(l))}catch(c){r(c)}},a=l=>l.done?i(l.value):Promise.resolve(l.value).then(o,s);a((t=t.apply(n,e)).next())});var mc=lu((vi,ts)=>{(function(e,t){typeof vi=="object"&&typeof ts=="object"?ts.exports=t():typeof define=="function"&&define.amd?define([],t):typeof vi=="object"?vi.ClipboardJS=t():e.ClipboardJS=t()})(vi,function(){return(function(){var n={686:(function(i,r,o){"use strict";o.d(r,{default:function(){return Re}});var s=o(279),a=o.n(s),l=o(370),c=o.n(l),u=o(817),d=o.n(u);function p(W){try{return document.execCommand(W)}catch(M){return!1}}var y=function(M){var D=d()(M);return p("cut"),D},m=y;function v(W){var M=document.documentElement.getAttribute("dir")==="rtl",D=document.createElement("textarea");D.style.fontSize="12pt",D.style.border="0",D.style.padding="0",D.style.margin="0",D.style.position="absolute",D.style[M?"right":"left"]="-9999px";var B=window.pageYOffset||document.documentElement.scrollTop;return D.style.top="".concat(B,"px"),D.setAttribute("readonly",""),D.value=W,D}var w=function(M,D){var B=v(M);D.container.appendChild(B);var V=d()(B);return p("copy"),B.remove(),V},T=function(M){var D=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{container:document.body},B="";return typeof M=="string"?B=w(M,D):M instanceof HTMLInputElement&&!["text","search","url","tel","password"].includes(M==null?void 0:M.type)?B=w(M.value,D):(B=d()(M),p("copy")),B},_=T;function S(W){"@babel/helpers - typeof";return typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?S=function(D){return typeof D}:S=function(D){return D&&typeof Symbol=="function"&&D.constructor===Symbol&&D!==Symbol.prototype?"symbol":typeof D},S(W)}var A=function(){var M=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{},D=M.action,B=D===void 0?"copy":D,V=M.container,q=M.target,U=M.text;if(B!=="copy"&&B!=="cut")throw new Error('Invalid "action" value, use either "copy" or "cut"');if(q!==void 0)if(q&&S(q)==="object"&&q.nodeType===1){if(B==="copy"&&q.hasAttribute("disabled"))throw new Error('Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute');if(B==="cut"&&(q.hasAttribute("readonly")||q.hasAttribute("disabled")))throw new Error(`Invalid "target" attribute. You can't cut text from elements with "readonly" or "disabled" attributes`)}else throw new Error('Invalid "target" value, use a valid Element');if(U)return _(U,{container:V});if(q)return B==="cut"?m(q):_(q,{container:V})},K=A;function z(W){"@babel/helpers - typeof";return typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?z=function(D){return typeof D}:z=function(D){return D&&typeof Symbol=="function"&&D.constructor===Symbol&&D!==Symbol.prototype?"symbol":typeof D},z(W)}function L(W,M){if(!(W instanceof M))throw new TypeError("Cannot call a class as a function")}function H(W,M){for(var D=0;D0&&arguments[0]!==void 0?arguments[0]:{};this.action=typeof V.action=="function"?V.action:this.defaultAction,this.target=typeof V.target=="function"?V.target:this.defaultTarget,this.text=typeof V.text=="function"?V.text:this.defaultText,this.container=z(V.container)==="object"?V.container:document.body}},{key:"listenClick",value:function(V){var q=this;this.listener=c()(V,"click",function(U){return q.onClick(U)})}},{key:"onClick",value:function(V){var q=V.delegateTarget||V.currentTarget,U=this.action(q)||"copy",Z=K({action:U,container:this.container,target:this.target(q),text:this.text(q)});this.emit(Z?"success":"error",{action:U,text:Z,trigger:q,clearSelection:function(){q&&q.focus(),window.getSelection().removeAllRanges()}})}},{key:"defaultAction",value:function(V){return ne("action",V)}},{key:"defaultTarget",value:function(V){var q=ne("target",V);if(q)return document.querySelector(q)}},{key:"defaultText",value:function(V){return ne("text",V)}},{key:"destroy",value:function(){this.listener.destroy()}}],[{key:"copy",value:function(V){var q=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{container:document.body};return _(V,q)}},{key:"cut",value:function(V){return m(V)}},{key:"isSupported",value:function(){var V=arguments.length>0&&arguments[0]!==void 0?arguments[0]:["copy","cut"],q=typeof V=="string"?[V]:V,U=!!document.queryCommandSupported;return q.forEach(function(Z){U=U&&!!document.queryCommandSupported(Z)}),U}}]),D})(a()),Re=qe}),828:(function(i){var r=9;if(typeof Element!="undefined"&&!Element.prototype.matches){var o=Element.prototype;o.matches=o.matchesSelector||o.mozMatchesSelector||o.msMatchesSelector||o.oMatchesSelector||o.webkitMatchesSelector}function s(a,l){for(;a&&a.nodeType!==r;){if(typeof a.matches=="function"&&a.matches(l))return a;a=a.parentNode}}i.exports=s}),438:(function(i,r,o){var s=o(828);function a(u,d,p,y,m){var v=c.apply(this,arguments);return u.addEventListener(p,v,m),{destroy:function(){u.removeEventListener(p,v,m)}}}function l(u,d,p,y,m){return typeof u.addEventListener=="function"?a.apply(null,arguments):typeof p=="function"?a.bind(null,document).apply(null,arguments):(typeof u=="string"&&(u=document.querySelectorAll(u)),Array.prototype.map.call(u,function(v){return a(v,d,p,y,m)}))}function c(u,d,p,y){return function(m){m.delegateTarget=s(m.target,d),m.delegateTarget&&y.call(u,m)}}i.exports=l}),879:(function(i,r){r.node=function(o){return o!==void 0&&o instanceof HTMLElement&&o.nodeType===1},r.nodeList=function(o){var s=Object.prototype.toString.call(o);return o!==void 0&&(s==="[object NodeList]"||s==="[object HTMLCollection]")&&"length"in o&&(o.length===0||r.node(o[0]))},r.string=function(o){return typeof o=="string"||o instanceof String},r.fn=function(o){var s=Object.prototype.toString.call(o);return s==="[object Function]"}}),370:(function(i,r,o){var s=o(879),a=o(438);function l(p,y,m){if(!p&&!y&&!m)throw new Error("Missing required arguments");if(!s.string(y))throw new TypeError("Second argument must be a String");if(!s.fn(m))throw new TypeError("Third argument must be a Function");if(s.node(p))return c(p,y,m);if(s.nodeList(p))return u(p,y,m);if(s.string(p))return d(p,y,m);throw new TypeError("First argument must be a String, HTMLElement, HTMLCollection, or NodeList")}function c(p,y,m){return p.addEventListener(y,m),{destroy:function(){p.removeEventListener(y,m)}}}function u(p,y,m){return Array.prototype.forEach.call(p,function(v){v.addEventListener(y,m)}),{destroy:function(){Array.prototype.forEach.call(p,function(v){v.removeEventListener(y,m)})}}}function d(p,y,m){return a(document.body,p,y,m)}i.exports=l}),817:(function(i){function r(o){var s;if(o.nodeName==="SELECT")o.focus(),s=o.value;else if(o.nodeName==="INPUT"||o.nodeName==="TEXTAREA"){var a=o.hasAttribute("readonly");a||o.setAttribute("readonly",""),o.select(),o.setSelectionRange(0,o.value.length),a||o.removeAttribute("readonly"),s=o.value}else{o.hasAttribute("contenteditable")&&o.focus();var l=window.getSelection(),c=document.createRange();c.selectNodeContents(o),l.removeAllRanges(),l.addRange(c),s=l.toString()}return s}i.exports=r}),279:(function(i){function r(){}r.prototype={on:function(o,s,a){var l=this.e||(this.e={});return(l[o]||(l[o]=[])).push({fn:s,ctx:a}),this},once:function(o,s,a){var l=this;function c(){l.off(o,c),s.apply(a,arguments)}return c._=s,this.on(o,c,a)},emit:function(o){var s=[].slice.call(arguments,1),a=((this.e||(this.e={}))[o]||[]).slice(),l=0,c=a.length;for(l;lws,afterRead:()=>Es,afterWrite:()=>Cs,applyStyles:()=>hn,arrow:()=>Ni,auto:()=>jn,basePlacements:()=>lt,beforeMain:()=>bs,beforeRead:()=>vs,beforeWrite:()=>xs,bottom:()=>me,clippingParents:()=>Ur,computeStyles:()=>mn,createPopper:()=>Jn,createPopperBase:()=>Is,createPopperLite:()=>Rs,detectOverflow:()=>ke,end:()=>bt,eventListeners:()=>gn,flip:()=>Pi,hide:()=>Fi,left:()=>he,main:()=>_s,modifierPhases:()=>Gr,offset:()=>$i,placements:()=>qn,popper:()=>$t,popperGenerator:()=>Yt,popperOffsets:()=>En,preventOverflow:()=>Bi,read:()=>ys,reference:()=>Yr,right:()=>pe,start:()=>rt,top:()=>de,variationPlacements:()=>ki,viewport:()=>Wn,write:()=>Ts});var de="top",me="bottom",pe="right",he="left",jn="auto",lt=[de,me,pe,he],rt="start",bt="end",Ur="clippingParents",Wn="viewport",$t="popper",Yr="reference",ki=lt.reduce(function(n,e){return n.concat([e+"-"+rt,e+"-"+bt])},[]),qn=[].concat(lt,[jn]).reduce(function(n,e){return n.concat([e,e+"-"+rt,e+"-"+bt])},[]),vs="beforeRead",ys="read",Es="afterRead",bs="beforeMain",_s="main",ws="afterMain",xs="beforeWrite",Ts="write",Cs="afterWrite",Gr=[vs,ys,Es,bs,_s,ws,xs,Ts,Cs];function we(n){return n?(n.nodeName||"").toLowerCase():null}function ce(n){if(n==null)return window;if(n.toString()!=="[object Window]"){var e=n.ownerDocument;return e&&e.defaultView||window}return n}function Ue(n){var e=ce(n).Element;return n instanceof e||n instanceof Element}function be(n){var e=ce(n).HTMLElement;return n instanceof e||n instanceof HTMLElement}function fn(n){if(typeof ShadowRoot=="undefined")return!1;var e=ce(n).ShadowRoot;return n instanceof e||n instanceof ShadowRoot}function du(n){var e=n.state;Object.keys(e.elements).forEach(function(t){var i=e.styles[t]||{},r=e.attributes[t]||{},o=e.elements[t];!be(o)||!we(o)||(Object.assign(o.style,i),Object.keys(r).forEach(function(s){var a=r[s];a===!1?o.removeAttribute(s):o.setAttribute(s,a===!0?"":a)}))})}function fu(n){var e=n.state,t={popper:{position:e.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(e.elements.popper.style,t.popper),e.styles=t,e.elements.arrow&&Object.assign(e.elements.arrow.style,t.arrow),function(){Object.keys(e.elements).forEach(function(i){var r=e.elements[i],o=e.attributes[i]||{},s=Object.keys(e.styles.hasOwnProperty(i)?e.styles[i]:t[i]),a=s.reduce(function(l,c){return l[c]="",l},{});!be(r)||!we(r)||(Object.assign(r.style,a),Object.keys(o).forEach(function(l){r.removeAttribute(l)}))})}}var hn={name:"applyStyles",enabled:!0,phase:"write",fn:du,effect:fu,requires:["computeStyles"]};function xe(n){return n.split("-")[0]}var Ze=Math.max,Bt=Math.min,ct=Math.round;function pn(){var n=navigator.userAgentData;return n!=null&&n.brands&&Array.isArray(n.brands)?n.brands.map(function(e){return e.brand+"/"+e.version}).join(" "):navigator.userAgent}function Un(){return!/^((?!chrome|android).)*safari/i.test(pn())}function Ye(n,e,t){e===void 0&&(e=!1),t===void 0&&(t=!1);var i=n.getBoundingClientRect(),r=1,o=1;e&&be(n)&&(r=n.offsetWidth>0&&ct(i.width)/n.offsetWidth||1,o=n.offsetHeight>0&&ct(i.height)/n.offsetHeight||1);var s=Ue(n)?ce(n):window,a=s.visualViewport,l=!Un()&&t,c=(i.left+(l&&a?a.offsetLeft:0))/r,u=(i.top+(l&&a?a.offsetTop:0))/o,d=i.width/r,p=i.height/o;return{width:d,height:p,top:u,right:c+d,bottom:u+p,left:c,x:c,y:u}}function Vt(n){var e=Ye(n),t=n.offsetWidth,i=n.offsetHeight;return Math.abs(e.width-t)<=1&&(t=e.width),Math.abs(e.height-i)<=1&&(i=e.height),{x:n.offsetLeft,y:n.offsetTop,width:t,height:i}}function Yn(n,e){var t=e.getRootNode&&e.getRootNode();if(n.contains(e))return!0;if(t&&fn(t)){var i=e;do{if(i&&n.isSameNode(i))return!0;i=i.parentNode||i.host}while(i)}return!1}function Me(n){return ce(n).getComputedStyle(n)}function Kr(n){return["table","td","th"].indexOf(we(n))>=0}function Ae(n){return((Ue(n)?n.ownerDocument:n.document)||window.document).documentElement}function ut(n){return we(n)==="html"?n:n.assignedSlot||n.parentNode||(fn(n)?n.host:null)||Ae(n)}function As(n){return!be(n)||Me(n).position==="fixed"?null:n.offsetParent}function hu(n){var e=/firefox/i.test(pn()),t=/Trident/i.test(pn());if(t&&be(n)){var i=Me(n);if(i.position==="fixed")return null}var r=ut(n);for(fn(r)&&(r=r.host);be(r)&&["html","body"].indexOf(we(r))<0;){var o=Me(r);if(o.transform!=="none"||o.perspective!=="none"||o.contain==="paint"||["transform","perspective"].indexOf(o.willChange)!==-1||e&&o.willChange==="filter"||e&&o.filter&&o.filter!=="none")return r;r=r.parentNode}return null}function et(n){for(var e=ce(n),t=As(n);t&&Kr(t)&&Me(t).position==="static";)t=As(t);return t&&(we(t)==="html"||we(t)==="body"&&Me(t).position==="static")?e:t||hu(n)||e}function zt(n){return["top","bottom"].indexOf(n)>=0?"x":"y"}function jt(n,e,t){return Ze(n,Bt(e,t))}function Ss(n,e,t){var i=jt(n,e,t);return i>t?t:i}function Gn(){return{top:0,right:0,bottom:0,left:0}}function Kn(n){return Object.assign({},Gn(),n)}function Xn(n,e){return e.reduce(function(t,i){return t[i]=n,t},{})}var pu=function(e,t){return e=typeof e=="function"?e(Object.assign({},t.rects,{placement:t.placement})):e,Kn(typeof e!="number"?e:Xn(e,lt))};function mu(n){var e,t=n.state,i=n.name,r=n.options,o=t.elements.arrow,s=t.modifiersData.popperOffsets,a=xe(t.placement),l=zt(a),c=[he,pe].indexOf(a)>=0,u=c?"height":"width";if(!(!o||!s)){var d=pu(r.padding,t),p=Vt(o),y=l==="y"?de:he,m=l==="y"?me:pe,v=t.rects.reference[u]+t.rects.reference[l]-s[l]-t.rects.popper[u],w=s[l]-t.rects.reference[l],T=et(o),_=T?l==="y"?T.clientHeight||0:T.clientWidth||0:0,S=v/2-w/2,A=d[y],K=_-p[u]-d[m],z=_/2-p[u]/2+S,L=jt(A,z,K),H=l;t.modifiersData[i]=(e={},e[H]=L,e.centerOffset=L-z,e)}}function gu(n){var e=n.state,t=n.options,i=t.element,r=i===void 0?"[data-popper-arrow]":i;r!=null&&(typeof r=="string"&&(r=e.elements.popper.querySelector(r),!r)||Yn(e.elements.popper,r)&&(e.elements.arrow=r))}var Ni={name:"arrow",enabled:!0,phase:"main",fn:mu,effect:gu,requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function Ge(n){return n.split("-")[1]}var vu={top:"auto",right:"auto",bottom:"auto",left:"auto"};function yu(n,e){var t=n.x,i=n.y,r=e.devicePixelRatio||1;return{x:ct(t*r)/r||0,y:ct(i*r)/r||0}}function Ds(n){var e,t=n.popper,i=n.popperRect,r=n.placement,o=n.variation,s=n.offsets,a=n.position,l=n.gpuAcceleration,c=n.adaptive,u=n.roundOffsets,d=n.isFixed,p=s.x,y=p===void 0?0:p,m=s.y,v=m===void 0?0:m,w=typeof u=="function"?u({x:y,y:v}):{x:y,y:v};y=w.x,v=w.y;var T=s.hasOwnProperty("x"),_=s.hasOwnProperty("y"),S=he,A=de,K=window;if(c){var z=et(t),L="clientHeight",H="clientWidth";if(z===ce(t)&&(z=Ae(t),Me(z).position!=="static"&&a==="absolute"&&(L="scrollHeight",H="scrollWidth")),z=z,r===de||(r===he||r===pe)&&o===bt){A=me;var N=d&&z===K&&K.visualViewport?K.visualViewport.height:z[L];v-=N-i.height,v*=l?1:-1}if(r===he||(r===de||r===me)&&o===bt){S=pe;var Y=d&&z===K&&K.visualViewport?K.visualViewport.width:z[H];y-=Y-i.width,y*=l?1:-1}}var $=Object.assign({position:a},c&&vu),ie=u===!0?yu({x:y,y:v},ce(t)):{x:y,y:v};if(y=ie.x,v=ie.y,l){var J;return Object.assign({},$,(J={},J[A]=_?"0":"",J[S]=T?"0":"",J.transform=(K.devicePixelRatio||1)<=1?"translate("+y+"px, "+v+"px)":"translate3d("+y+"px, "+v+"px, 0)",J))}return Object.assign({},$,(e={},e[A]=_?v+"px":"",e[S]=T?y+"px":"",e.transform="",e))}function Eu(n){var e=n.state,t=n.options,i=t.gpuAcceleration,r=i===void 0?!0:i,o=t.adaptive,s=o===void 0?!0:o,a=t.roundOffsets,l=a===void 0?!0:a,c={placement:xe(e.placement),variation:Ge(e.placement),popper:e.elements.popper,popperRect:e.rects.popper,gpuAcceleration:r,isFixed:e.options.strategy==="fixed"};e.modifiersData.popperOffsets!=null&&(e.styles.popper=Object.assign({},e.styles.popper,Ds(Object.assign({},c,{offsets:e.modifiersData.popperOffsets,position:e.options.strategy,adaptive:s,roundOffsets:l})))),e.modifiersData.arrow!=null&&(e.styles.arrow=Object.assign({},e.styles.arrow,Ds(Object.assign({},c,{offsets:e.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:l})))),e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-placement":e.placement})}var mn={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:Eu,data:{}};var Ii={passive:!0};function bu(n){var e=n.state,t=n.instance,i=n.options,r=i.scroll,o=r===void 0?!0:r,s=i.resize,a=s===void 0?!0:s,l=ce(e.elements.popper),c=[].concat(e.scrollParents.reference,e.scrollParents.popper);return o&&c.forEach(function(u){u.addEventListener("scroll",t.update,Ii)}),a&&l.addEventListener("resize",t.update,Ii),function(){o&&c.forEach(function(u){u.removeEventListener("scroll",t.update,Ii)}),a&&l.removeEventListener("resize",t.update,Ii)}}var gn={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:bu,data:{}};var _u={left:"right",right:"left",bottom:"top",top:"bottom"};function vn(n){return n.replace(/left|right|bottom|top/g,function(e){return _u[e]})}var wu={start:"end",end:"start"};function Ri(n){return n.replace(/start|end/g,function(e){return wu[e]})}function Wt(n){var e=ce(n),t=e.pageXOffset,i=e.pageYOffset;return{scrollLeft:t,scrollTop:i}}function qt(n){return Ye(Ae(n)).left+Wt(n).scrollLeft}function Xr(n,e){var t=ce(n),i=Ae(n),r=t.visualViewport,o=i.clientWidth,s=i.clientHeight,a=0,l=0;if(r){o=r.width,s=r.height;var c=Un();(c||!c&&e==="fixed")&&(a=r.offsetLeft,l=r.offsetTop)}return{width:o,height:s,x:a+qt(n),y:l}}function Qr(n){var e,t=Ae(n),i=Wt(n),r=(e=n.ownerDocument)==null?void 0:e.body,o=Ze(t.scrollWidth,t.clientWidth,r?r.scrollWidth:0,r?r.clientWidth:0),s=Ze(t.scrollHeight,t.clientHeight,r?r.scrollHeight:0,r?r.clientHeight:0),a=-i.scrollLeft+qt(n),l=-i.scrollTop;return Me(r||t).direction==="rtl"&&(a+=Ze(t.clientWidth,r?r.clientWidth:0)-o),{width:o,height:s,x:a,y:l}}function Ut(n){var e=Me(n),t=e.overflow,i=e.overflowX,r=e.overflowY;return/auto|scroll|overlay|hidden/.test(t+r+i)}function Hi(n){return["html","body","#document"].indexOf(we(n))>=0?n.ownerDocument.body:be(n)&&Ut(n)?n:Hi(ut(n))}function _t(n,e){var t;e===void 0&&(e=[]);var i=Hi(n),r=i===((t=n.ownerDocument)==null?void 0:t.body),o=ce(i),s=r?[o].concat(o.visualViewport||[],Ut(i)?i:[]):i,a=e.concat(s);return r?a:a.concat(_t(ut(s)))}function yn(n){return Object.assign({},n,{left:n.x,top:n.y,right:n.x+n.width,bottom:n.y+n.height})}function xu(n,e){var t=Ye(n,!1,e==="fixed");return t.top=t.top+n.clientTop,t.left=t.left+n.clientLeft,t.bottom=t.top+n.clientHeight,t.right=t.left+n.clientWidth,t.width=n.clientWidth,t.height=n.clientHeight,t.x=t.left,t.y=t.top,t}function Os(n,e,t){return e===Wn?yn(Xr(n,t)):Ue(e)?xu(e,t):yn(Qr(Ae(n)))}function Tu(n){var e=_t(ut(n)),t=["absolute","fixed"].indexOf(Me(n).position)>=0,i=t&&be(n)?et(n):n;return Ue(i)?e.filter(function(r){return Ue(r)&&Yn(r,i)&&we(r)!=="body"}):[]}function Jr(n,e,t,i){var r=e==="clippingParents"?Tu(n):[].concat(e),o=[].concat(r,[t]),s=o[0],a=o.reduce(function(l,c){var u=Os(n,c,i);return l.top=Ze(u.top,l.top),l.right=Bt(u.right,l.right),l.bottom=Bt(u.bottom,l.bottom),l.left=Ze(u.left,l.left),l},Os(n,s,i));return a.width=a.right-a.left,a.height=a.bottom-a.top,a.x=a.left,a.y=a.top,a}function Qn(n){var e=n.reference,t=n.element,i=n.placement,r=i?xe(i):null,o=i?Ge(i):null,s=e.x+e.width/2-t.width/2,a=e.y+e.height/2-t.height/2,l;switch(r){case de:l={x:s,y:e.y-t.height};break;case me:l={x:s,y:e.y+e.height};break;case pe:l={x:e.x+e.width,y:a};break;case he:l={x:e.x-t.width,y:a};break;default:l={x:e.x,y:e.y}}var c=r?zt(r):null;if(c!=null){var u=c==="y"?"height":"width";switch(o){case rt:l[c]=l[c]-(e[u]/2-t[u]/2);break;case bt:l[c]=l[c]+(e[u]/2-t[u]/2);break;default:}}return l}function ke(n,e){e===void 0&&(e={});var t=e,i=t.placement,r=i===void 0?n.placement:i,o=t.strategy,s=o===void 0?n.strategy:o,a=t.boundary,l=a===void 0?Ur:a,c=t.rootBoundary,u=c===void 0?Wn:c,d=t.elementContext,p=d===void 0?$t:d,y=t.altBoundary,m=y===void 0?!1:y,v=t.padding,w=v===void 0?0:v,T=Kn(typeof w!="number"?w:Xn(w,lt)),_=p===$t?Yr:$t,S=n.rects.popper,A=n.elements[m?_:p],K=Jr(Ue(A)?A:A.contextElement||Ae(n.elements.popper),l,u,s),z=Ye(n.elements.reference),L=Qn({reference:z,element:S,strategy:"absolute",placement:r}),H=yn(Object.assign({},S,L)),N=p===$t?H:z,Y={top:K.top-N.top+T.top,bottom:N.bottom-K.bottom+T.bottom,left:K.left-N.left+T.left,right:N.right-K.right+T.right},$=n.modifiersData.offset;if(p===$t&&$){var ie=$[r];Object.keys(Y).forEach(function(J){var Te=[pe,me].indexOf(J)>=0?1:-1,Ce=[de,me].indexOf(J)>=0?"y":"x";Y[J]+=ie[Ce]*Te})}return Y}function Zr(n,e){e===void 0&&(e={});var t=e,i=t.placement,r=t.boundary,o=t.rootBoundary,s=t.padding,a=t.flipVariations,l=t.allowedAutoPlacements,c=l===void 0?qn:l,u=Ge(i),d=u?a?ki:ki.filter(function(m){return Ge(m)===u}):lt,p=d.filter(function(m){return c.indexOf(m)>=0});p.length===0&&(p=d);var y=p.reduce(function(m,v){return m[v]=ke(n,{placement:v,boundary:r,rootBoundary:o,padding:s})[xe(v)],m},{});return Object.keys(y).sort(function(m,v){return y[m]-y[v]})}function Cu(n){if(xe(n)===jn)return[];var e=vn(n);return[Ri(n),e,Ri(e)]}function Au(n){var e=n.state,t=n.options,i=n.name;if(!e.modifiersData[i]._skip){for(var r=t.mainAxis,o=r===void 0?!0:r,s=t.altAxis,a=s===void 0?!0:s,l=t.fallbackPlacements,c=t.padding,u=t.boundary,d=t.rootBoundary,p=t.altBoundary,y=t.flipVariations,m=y===void 0?!0:y,v=t.allowedAutoPlacements,w=e.options.placement,T=xe(w),_=T===w,S=l||(_||!m?[vn(w)]:Cu(w)),A=[w].concat(S).reduce(function(V,q){return V.concat(xe(q)===jn?Zr(e,{placement:q,boundary:u,rootBoundary:d,padding:c,flipVariations:m,allowedAutoPlacements:v}):q)},[]),K=e.rects.reference,z=e.rects.popper,L=new Map,H=!0,N=A[0],Y=0;Y=0,Ce=Te?"width":"height",se=ke(e,{placement:$,boundary:u,rootBoundary:d,altBoundary:p,padding:c}),ne=Te?J?pe:he:J?me:de;K[Ce]>z[Ce]&&(ne=vn(ne));var qe=vn(ne),Re=[];if(o&&Re.push(se[ie]<=0),a&&Re.push(se[ne]<=0,se[qe]<=0),Re.every(function(V){return V})){N=$,H=!1;break}L.set($,Re)}if(H)for(var W=m?3:1,M=function(q){var U=A.find(function(Z){var oe=L.get(Z);if(oe)return oe.slice(0,q).every(function(Et){return Et})});if(U)return N=U,"break"},D=W;D>0;D--){var B=M(D);if(B==="break")break}e.placement!==N&&(e.modifiersData[i]._skip=!0,e.placement=N,e.reset=!0)}}var Pi={name:"flip",enabled:!0,phase:"main",fn:Au,requiresIfExists:["offset"],data:{_skip:!1}};function Ls(n,e,t){return t===void 0&&(t={x:0,y:0}),{top:n.top-e.height-t.y,right:n.right-e.width+t.x,bottom:n.bottom-e.height+t.y,left:n.left-e.width-t.x}}function Ms(n){return[de,pe,me,he].some(function(e){return n[e]>=0})}function Su(n){var e=n.state,t=n.name,i=e.rects.reference,r=e.rects.popper,o=e.modifiersData.preventOverflow,s=ke(e,{elementContext:"reference"}),a=ke(e,{altBoundary:!0}),l=Ls(s,i),c=Ls(a,r,o),u=Ms(l),d=Ms(c);e.modifiersData[t]={referenceClippingOffsets:l,popperEscapeOffsets:c,isReferenceHidden:u,hasPopperEscaped:d},e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-reference-hidden":u,"data-popper-escaped":d})}var Fi={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:Su};function Du(n,e,t){var i=xe(n),r=[he,de].indexOf(i)>=0?-1:1,o=typeof t=="function"?t(Object.assign({},e,{placement:n})):t,s=o[0],a=o[1];return s=s||0,a=(a||0)*r,[he,pe].indexOf(i)>=0?{x:a,y:s}:{x:s,y:a}}function Ou(n){var e=n.state,t=n.options,i=n.name,r=t.offset,o=r===void 0?[0,0]:r,s=qn.reduce(function(u,d){return u[d]=Du(d,e.rects,o),u},{}),a=s[e.placement],l=a.x,c=a.y;e.modifiersData.popperOffsets!=null&&(e.modifiersData.popperOffsets.x+=l,e.modifiersData.popperOffsets.y+=c),e.modifiersData[i]=s}var $i={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:Ou};function Lu(n){var e=n.state,t=n.name;e.modifiersData[t]=Qn({reference:e.rects.reference,element:e.rects.popper,strategy:"absolute",placement:e.placement})}var En={name:"popperOffsets",enabled:!0,phase:"read",fn:Lu,data:{}};function eo(n){return n==="x"?"y":"x"}function Mu(n){var e=n.state,t=n.options,i=n.name,r=t.mainAxis,o=r===void 0?!0:r,s=t.altAxis,a=s===void 0?!1:s,l=t.boundary,c=t.rootBoundary,u=t.altBoundary,d=t.padding,p=t.tether,y=p===void 0?!0:p,m=t.tetherOffset,v=m===void 0?0:m,w=ke(e,{boundary:l,rootBoundary:c,padding:d,altBoundary:u}),T=xe(e.placement),_=Ge(e.placement),S=!_,A=zt(T),K=eo(A),z=e.modifiersData.popperOffsets,L=e.rects.reference,H=e.rects.popper,N=typeof v=="function"?v(Object.assign({},e.rects,{placement:e.placement})):v,Y=typeof N=="number"?{mainAxis:N,altAxis:N}:Object.assign({mainAxis:0,altAxis:0},N),$=e.modifiersData.offset?e.modifiersData.offset[e.placement]:null,ie={x:0,y:0};if(z){if(o){var J,Te=A==="y"?de:he,Ce=A==="y"?me:pe,se=A==="y"?"height":"width",ne=z[A],qe=ne+w[Te],Re=ne-w[Ce],W=y?-H[se]/2:0,M=_===rt?L[se]:H[se],D=_===rt?-H[se]:-L[se],B=e.elements.arrow,V=y&&B?Vt(B):{width:0,height:0},q=e.modifiersData["arrow#persistent"]?e.modifiersData["arrow#persistent"].padding:Gn(),U=q[Te],Z=q[Ce],oe=jt(0,L[se],V[se]),Et=S?L[se]/2-W-oe-U-Y.mainAxis:M-oe-U-Y.mainAxis,$r=S?-L[se]/2+W+oe+Z+Y.mainAxis:D+oe+Z+Y.mainAxis,on=e.elements.arrow&&et(e.elements.arrow),sn=on?A==="y"?on.clientTop||0:on.clientLeft||0:0,Ti=(J=$==null?void 0:$[A])!=null?J:0,Br=ne+Et-Ti-sn,Ci=ne+$r-Ti,Ai=jt(y?Bt(qe,Br):qe,ne,y?Ze(Re,Ci):Re);z[A]=Ai,ie[A]=Ai-ne}if(a){var $n,Si=A==="x"?de:he,an=A==="x"?me:pe,ot=z[K],ln=K==="y"?"height":"width",Bn=ot+w[Si],cn=ot-w[an],un=[de,he].indexOf(T)!==-1,Ft=($n=$==null?void 0:$[K])!=null?$n:0,Di=un?Bn:ot-L[ln]-H[ln]-Ft+Y.altAxis,Vn=un?ot+L[ln]+H[ln]-Ft-Y.altAxis:cn,Oi=y&&un?Ss(Di,ot,Vn):jt(y?Di:Bn,ot,y?Vn:cn);z[K]=Oi,ie[K]=Oi-ot}e.modifiersData[i]=ie}}var Bi={name:"preventOverflow",enabled:!0,phase:"main",fn:Mu,requiresIfExists:["offset"]};function to(n){return{scrollLeft:n.scrollLeft,scrollTop:n.scrollTop}}function no(n){return n===ce(n)||!be(n)?Wt(n):to(n)}function ku(n){var e=n.getBoundingClientRect(),t=ct(e.width)/n.offsetWidth||1,i=ct(e.height)/n.offsetHeight||1;return t!==1||i!==1}function io(n,e,t){t===void 0&&(t=!1);var i=be(e),r=be(e)&&ku(e),o=Ae(e),s=Ye(n,r,t),a={scrollLeft:0,scrollTop:0},l={x:0,y:0};return(i||!i&&!t)&&((we(e)!=="body"||Ut(o))&&(a=no(e)),be(e)?(l=Ye(e,!0),l.x+=e.clientLeft,l.y+=e.clientTop):o&&(l.x=qt(o))),{x:s.left+a.scrollLeft-l.x,y:s.top+a.scrollTop-l.y,width:s.width,height:s.height}}function Nu(n){var e=new Map,t=new Set,i=[];n.forEach(function(o){e.set(o.name,o)});function r(o){t.add(o.name);var s=[].concat(o.requires||[],o.requiresIfExists||[]);s.forEach(function(a){if(!t.has(a)){var l=e.get(a);l&&r(l)}}),i.push(o)}return n.forEach(function(o){t.has(o.name)||r(o)}),i}function ro(n){var e=Nu(n);return Gr.reduce(function(t,i){return t.concat(e.filter(function(r){return r.phase===i}))},[])}function oo(n){var e;return function(){return e||(e=new Promise(function(t){Promise.resolve().then(function(){e=void 0,t(n())})})),e}}function so(n){var e=n.reduce(function(t,i){var r=t[i.name];return t[i.name]=r?Object.assign({},r,i,{options:Object.assign({},r.options,i.options),data:Object.assign({},r.data,i.data)}):i,t},{});return Object.keys(e).map(function(t){return e[t]})}var ks={placement:"bottom",modifiers:[],strategy:"absolute"};function Ns(){for(var n=arguments.length,e=new Array(n),t=0;t(n&&window.CSS&&window.CSS.escape&&(n=n.replace(/#([^\s"#']+)/g,(e,t)=>`#${CSS.escape(t)}`)),n),Fu=n=>n==null?`${n}`:Object.prototype.toString.call(n).match(/\s([a-z]+)/i)[1].toLowerCase(),$u=n=>{do n+=Math.floor(Math.random()*Hu);while(document.getElementById(n));return n},Bu=n=>{if(!n)return 0;let{transitionDuration:e,transitionDelay:t}=window.getComputedStyle(n),i=Number.parseFloat(e),r=Number.parseFloat(t);return!i&&!r?0:(e=e.split(",")[0],t=t.split(",")[0],(Number.parseFloat(e)+Number.parseFloat(t))*Pu)},da=n=>{n.dispatchEvent(new Event(To))},dt=n=>!n||typeof n!="object"?!1:(typeof n.jquery!="undefined"&&(n=n[0]),typeof n.nodeType!="undefined"),xt=n=>dt(n)?n.jquery?n[0]:n:typeof n=="string"&&n.length>0?document.querySelector(ua(n)):null,An=n=>{if(!dt(n)||n.getClientRects().length===0)return!1;let e=getComputedStyle(n).getPropertyValue("visibility")==="visible",t=n.closest("details:not([open])");if(!t)return e;if(t!==n){let i=n.closest("summary");if(i&&i.parentNode!==t||i===null)return!1}return e},Tt=n=>!n||n.nodeType!==Node.ELEMENT_NODE||n.classList.contains("disabled")?!0:typeof n.disabled!="undefined"?n.disabled:n.hasAttribute("disabled")&&n.getAttribute("disabled")!=="false",fa=n=>{if(!document.documentElement.attachShadow)return null;if(typeof n.getRootNode=="function"){let e=n.getRootNode();return e instanceof ShadowRoot?e:null}return n instanceof ShadowRoot?n:n.parentNode?fa(n.parentNode):null},Xi=()=>{},ii=n=>{n.offsetHeight},ha=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,lo=[],Vu=n=>{document.readyState==="loading"?(lo.length||document.addEventListener("DOMContentLoaded",()=>{for(let e of lo)e()}),lo.push(n)):n()},Ke=()=>document.documentElement.dir==="rtl",Qe=n=>{Vu(()=>{let e=ha();if(e){let t=n.NAME,i=e.fn[t];e.fn[t]=n.jQueryInterface,e.fn[t].Constructor=n,e.fn[t].noConflict=()=>(e.fn[t]=i,n.jQueryInterface)}})},He=(n,e=[],t=n)=>typeof n=="function"?n.call(...e):t,pa=(n,e,t=!0)=>{if(!t){He(n);return}let r=Bu(e)+5,o=!1,s=({target:a})=>{a===e&&(o=!0,e.removeEventListener(To,s),He(n))};e.addEventListener(To,s),setTimeout(()=>{o||da(e)},r)},Do=(n,e,t,i)=>{let r=n.length,o=n.indexOf(e);return o===-1?!t&&i?n[r-1]:n[0]:(o+=t?1:-1,i&&(o=(o+r)%r),n[Math.max(0,Math.min(o,r-1))])},zu=/[^.]*(?=\..*)\.|.*/,ju=/\..*/,Wu=/::\d+$/,co={},Hs=1,ma={mouseenter:"mouseover",mouseleave:"mouseout"},qu=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function ga(n,e){return e&&`${e}::${Hs++}`||n.uidEvent||Hs++}function va(n){let e=ga(n);return n.uidEvent=e,co[e]=co[e]||{},co[e]}function Uu(n,e){return function t(i){return Oo(i,{delegateTarget:n}),t.oneOff&&x.off(n,i.type,e),e.apply(n,[i])}}function Yu(n,e,t){return function i(r){let o=n.querySelectorAll(e);for(let{target:s}=r;s&&s!==this;s=s.parentNode)for(let a of o)if(a===s)return Oo(r,{delegateTarget:s}),i.oneOff&&x.off(n,r.type,e,t),t.apply(s,[r])}}function ya(n,e,t=null){return Object.values(n).find(i=>i.callable===e&&i.delegationSelector===t)}function Ea(n,e,t){let i=typeof e=="string",r=i?t:e||t,o=ba(n);return qu.has(o)||(o=n),[i,r,o]}function Ps(n,e,t,i,r){if(typeof e!="string"||!n)return;let[o,s,a]=Ea(e,t,i);e in ma&&(s=(m=>function(v){if(!v.relatedTarget||v.relatedTarget!==v.delegateTarget&&!v.delegateTarget.contains(v.relatedTarget))return m.call(this,v)})(s));let l=va(n),c=l[a]||(l[a]={}),u=ya(c,s,o?t:null);if(u){u.oneOff=u.oneOff&&r;return}let d=ga(s,e.replace(zu,"")),p=o?Yu(n,t,s):Uu(n,s);p.delegationSelector=o?t:null,p.callable=s,p.oneOff=r,p.uidEvent=d,c[d]=p,n.addEventListener(a,p,o)}function Co(n,e,t,i,r){let o=ya(e[t],i,r);o&&(n.removeEventListener(t,o,!!r),delete e[t][o.uidEvent])}function Gu(n,e,t,i){let r=e[t]||{};for(let[o,s]of Object.entries(r))o.includes(i)&&Co(n,e,t,s.callable,s.delegationSelector)}function ba(n){return n=n.replace(ju,""),ma[n]||n}var x={on(n,e,t,i){Ps(n,e,t,i,!1)},one(n,e,t,i){Ps(n,e,t,i,!0)},off(n,e,t,i){if(typeof e!="string"||!n)return;let[r,o,s]=Ea(e,t,i),a=s!==e,l=va(n),c=l[s]||{},u=e.startsWith(".");if(typeof o!="undefined"){if(!Object.keys(c).length)return;Co(n,l,s,o,r?t:null);return}if(u)for(let d of Object.keys(l))Gu(n,l,d,e.slice(1));for(let[d,p]of Object.entries(c)){let y=d.replace(Wu,"");(!a||e.includes(y))&&Co(n,l,s,p.callable,p.delegationSelector)}},trigger(n,e,t){if(typeof e!="string"||!n)return null;let i=ha(),r=ba(e),o=e!==r,s=null,a=!0,l=!0,c=!1;o&&i&&(s=i.Event(e,t),i(n).trigger(s),a=!s.isPropagationStopped(),l=!s.isImmediatePropagationStopped(),c=s.isDefaultPrevented());let u=Oo(new Event(e,{bubbles:a,cancelable:!0}),t);return c&&u.preventDefault(),l&&n.dispatchEvent(u),u.defaultPrevented&&s&&s.preventDefault(),u}};function Oo(n,e={}){for(let[t,i]of Object.entries(e))try{n[t]=i}catch(r){Object.defineProperty(n,t,{configurable:!0,get(){return i}})}return n}function Fs(n){if(n==="true")return!0;if(n==="false")return!1;if(n===Number(n).toString())return Number(n);if(n===""||n==="null")return null;if(typeof n!="string")return n;try{return JSON.parse(decodeURIComponent(n))}catch(e){return n}}function uo(n){return n.replace(/[A-Z]/g,e=>`-${e.toLowerCase()}`)}var ft={setDataAttribute(n,e,t){n.setAttribute(`data-bs-${uo(e)}`,t)},removeDataAttribute(n,e){n.removeAttribute(`data-bs-${uo(e)}`)},getDataAttributes(n){if(!n)return{};let e={},t=Object.keys(n.dataset).filter(i=>i.startsWith("bs")&&!i.startsWith("bsConfig"));for(let i of t){let r=i.replace(/^bs/,"");r=r.charAt(0).toLowerCase()+r.slice(1),e[r]=Fs(n.dataset[i])}return e},getDataAttribute(n,e){return Fs(n.getAttribute(`data-bs-${uo(e)}`))}},Xt=class{static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(e){return e=this._mergeConfigObj(e),e=this._configAfterMerge(e),this._typeCheckConfig(e),e}_configAfterMerge(e){return e}_mergeConfigObj(e,t){let i=dt(t)?ft.getDataAttribute(t,"config"):{};return O(O(O(O({},this.constructor.Default),typeof i=="object"?i:{}),dt(t)?ft.getDataAttributes(t):{}),typeof e=="object"?e:{})}_typeCheckConfig(e,t=this.constructor.DefaultType){for(let[i,r]of Object.entries(t)){let o=e[i],s=dt(o)?"element":Fu(o);if(!new RegExp(r).test(s))throw new TypeError(`${this.constructor.NAME.toUpperCase()}: Option "${i}" provided type "${s}" but expected type "${r}".`)}}},Ku="5.3.8",je=class extends Xt{constructor(e,t){super(),e=xt(e),e&&(this._element=e,this._config=this._getConfig(t),ao.set(this._element,this.constructor.DATA_KEY,this))}dispose(){ao.remove(this._element,this.constructor.DATA_KEY),x.off(this._element,this.constructor.EVENT_KEY);for(let e of Object.getOwnPropertyNames(this))this[e]=null}_queueCallback(e,t,i=!0){pa(e,t,i)}_getConfig(e){return e=this._mergeConfigObj(e,this._element),e=this._configAfterMerge(e),this._typeCheckConfig(e),e}static getInstance(e){return ao.get(xt(e),this.DATA_KEY)}static getOrCreateInstance(e,t={}){return this.getInstance(e)||new this(e,typeof t=="object"?t:null)}static get VERSION(){return Ku}static get DATA_KEY(){return`bs.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}static eventName(e){return`${e}${this.EVENT_KEY}`}},fo=n=>{let e=n.getAttribute("data-bs-target");if(!e||e==="#"){let t=n.getAttribute("href");if(!t||!t.includes("#")&&!t.startsWith("."))return null;t.includes("#")&&!t.startsWith("#")&&(t=`#${t.split("#")[1]}`),e=t&&t!=="#"?t.trim():null}return e?e.split(",").map(t=>ua(t)).join(","):null},j={find(n,e=document.documentElement){return[].concat(...Element.prototype.querySelectorAll.call(e,n))},findOne(n,e=document.documentElement){return Element.prototype.querySelector.call(e,n)},children(n,e){return[].concat(...n.children).filter(t=>t.matches(e))},parents(n,e){let t=[],i=n.parentNode.closest(e);for(;i;)t.push(i),i=i.parentNode.closest(e);return t},prev(n,e){let t=n.previousElementSibling;for(;t;){if(t.matches(e))return[t];t=t.previousElementSibling}return[]},next(n,e){let t=n.nextElementSibling;for(;t;){if(t.matches(e))return[t];t=t.nextElementSibling}return[]},focusableChildren(n){let e=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map(t=>`${t}:not([tabindex^="-"])`).join(",");return this.find(e,n).filter(t=>!Tt(t)&&An(t))},getSelectorFromElement(n){let e=fo(n);return e&&j.findOne(e)?e:null},getElementFromSelector(n){let e=fo(n);return e?j.findOne(e):null},getMultipleElementsFromSelector(n){let e=fo(n);return e?j.find(e):[]}},rr=(n,e="hide")=>{let t=`click.dismiss${n.EVENT_KEY}`,i=n.NAME;x.on(document,t,`[data-bs-dismiss="${i}"]`,function(r){if(["A","AREA"].includes(this.tagName)&&r.preventDefault(),Tt(this))return;let o=j.getElementFromSelector(this)||this.closest(`.${i}`);n.getOrCreateInstance(o)[e]()})},Xu="alert",Qu="bs.alert",_a=`.${Qu}`,Ju=`close${_a}`,Zu=`closed${_a}`,ed="fade",td="show",Qi=class n extends je{static get NAME(){return Xu}close(){if(x.trigger(this._element,Ju).defaultPrevented)return;this._element.classList.remove(td);let t=this._element.classList.contains(ed);this._queueCallback(()=>this._destroyElement(),this._element,t)}_destroyElement(){this._element.remove(),x.trigger(this._element,Zu),this.dispose()}static jQueryInterface(e){return this.each(function(){let t=n.getOrCreateInstance(this);if(typeof e=="string"){if(t[e]===void 0||e.startsWith("_")||e==="constructor")throw new TypeError(`No method named "${e}"`);t[e](this)}})}};rr(Qi,"close");Qe(Qi);var nd="button",id="bs.button",rd=`.${id}`,od=".data-api",sd="active",$s='[data-bs-toggle="button"]',ad=`click${rd}${od}`,Ji=class n extends je{static get NAME(){return nd}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle(sd))}static jQueryInterface(e){return this.each(function(){let t=n.getOrCreateInstance(this);e==="toggle"&&t[e]()})}};x.on(document,ad,$s,n=>{n.preventDefault();let e=n.target.closest($s);Ji.getOrCreateInstance(e).toggle()});Qe(Ji);var ld="swipe",Sn=".bs.swipe",cd=`touchstart${Sn}`,ud=`touchmove${Sn}`,dd=`touchend${Sn}`,fd=`pointerdown${Sn}`,hd=`pointerup${Sn}`,pd="touch",md="pen",gd="pointer-event",vd=40,yd={endCallback:null,leftCallback:null,rightCallback:null},Ed={endCallback:"(function|null)",leftCallback:"(function|null)",rightCallback:"(function|null)"},Zi=class n extends Xt{constructor(e,t){super(),this._element=e,!(!e||!n.isSupported())&&(this._config=this._getConfig(t),this._deltaX=0,this._supportPointerEvents=!!window.PointerEvent,this._initEvents())}static get Default(){return yd}static get DefaultType(){return Ed}static get NAME(){return ld}dispose(){x.off(this._element,Sn)}_start(e){if(!this._supportPointerEvents){this._deltaX=e.touches[0].clientX;return}this._eventIsPointerPenTouch(e)&&(this._deltaX=e.clientX)}_end(e){this._eventIsPointerPenTouch(e)&&(this._deltaX=e.clientX-this._deltaX),this._handleSwipe(),He(this._config.endCallback)}_move(e){this._deltaX=e.touches&&e.touches.length>1?0:e.touches[0].clientX-this._deltaX}_handleSwipe(){let e=Math.abs(this._deltaX);if(e<=vd)return;let t=e/this._deltaX;this._deltaX=0,t&&He(t>0?this._config.rightCallback:this._config.leftCallback)}_initEvents(){this._supportPointerEvents?(x.on(this._element,fd,e=>this._start(e)),x.on(this._element,hd,e=>this._end(e)),this._element.classList.add(gd)):(x.on(this._element,cd,e=>this._start(e)),x.on(this._element,ud,e=>this._move(e)),x.on(this._element,dd,e=>this._end(e)))}_eventIsPointerPenTouch(e){return this._supportPointerEvents&&(e.pointerType===md||e.pointerType===pd)}static isSupported(){return"ontouchstart"in document.documentElement||navigator.maxTouchPoints>0}},bd="carousel",_d="bs.carousel",Dt=`.${_d}`,wa=".data-api",wd="ArrowLeft",xd="ArrowRight",Td=500,Zn="next",bn="prev",wn="left",Gi="right",Cd=`slide${Dt}`,ho=`slid${Dt}`,Ad=`keydown${Dt}`,Sd=`mouseenter${Dt}`,Dd=`mouseleave${Dt}`,Od=`dragstart${Dt}`,Ld=`load${Dt}${wa}`,Md=`click${Dt}${wa}`,xa="carousel",zi="active",kd="slide",Nd="carousel-item-end",Id="carousel-item-start",Rd="carousel-item-next",Hd="carousel-item-prev",Ta=".active",Ca=".carousel-item",Pd=Ta+Ca,Fd=".carousel-item img",$d=".carousel-indicators",Bd="[data-bs-slide], [data-bs-slide-to]",Vd='[data-bs-ride="carousel"]',zd={[wd]:Gi,[xd]:wn},jd={interval:5e3,keyboard:!0,pause:"hover",ride:!1,touch:!0,wrap:!0},Wd={interval:"(number|boolean)",keyboard:"boolean",pause:"(string|boolean)",ride:"(boolean|string)",touch:"boolean",wrap:"boolean"},ti=class n extends je{constructor(e,t){super(e,t),this._interval=null,this._activeElement=null,this._isSliding=!1,this.touchTimeout=null,this._swipeHelper=null,this._indicatorsElement=j.findOne($d,this._element),this._addEventListeners(),this._config.ride===xa&&this.cycle()}static get Default(){return jd}static get DefaultType(){return Wd}static get NAME(){return bd}next(){this._slide(Zn)}nextWhenVisible(){!document.hidden&&An(this._element)&&this.next()}prev(){this._slide(bn)}pause(){this._isSliding&&da(this._element),this._clearInterval()}cycle(){this._clearInterval(),this._updateInterval(),this._interval=setInterval(()=>this.nextWhenVisible(),this._config.interval)}_maybeEnableCycle(){if(this._config.ride){if(this._isSliding){x.one(this._element,ho,()=>this.cycle());return}this.cycle()}}to(e){let t=this._getItems();if(e>t.length-1||e<0)return;if(this._isSliding){x.one(this._element,ho,()=>this.to(e));return}let i=this._getItemIndex(this._getActive());if(i===e)return;let r=e>i?Zn:bn;this._slide(r,t[e])}dispose(){this._swipeHelper&&this._swipeHelper.dispose(),super.dispose()}_configAfterMerge(e){return e.defaultInterval=e.interval,e}_addEventListeners(){this._config.keyboard&&x.on(this._element,Ad,e=>this._keydown(e)),this._config.pause==="hover"&&(x.on(this._element,Sd,()=>this.pause()),x.on(this._element,Dd,()=>this._maybeEnableCycle())),this._config.touch&&Zi.isSupported()&&this._addTouchEventListeners()}_addTouchEventListeners(){for(let i of j.find(Fd,this._element))x.on(i,Od,r=>r.preventDefault());let t={leftCallback:()=>this._slide(this._directionToOrder(wn)),rightCallback:()=>this._slide(this._directionToOrder(Gi)),endCallback:()=>{this._config.pause==="hover"&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout(()=>this._maybeEnableCycle(),Td+this._config.interval))}};this._swipeHelper=new Zi(this._element,t)}_keydown(e){if(/input|textarea/i.test(e.target.tagName))return;let t=zd[e.key];t&&(e.preventDefault(),this._slide(this._directionToOrder(t)))}_getItemIndex(e){return this._getItems().indexOf(e)}_setActiveIndicatorElement(e){if(!this._indicatorsElement)return;let t=j.findOne(Ta,this._indicatorsElement);t.classList.remove(zi),t.removeAttribute("aria-current");let i=j.findOne(`[data-bs-slide-to="${e}"]`,this._indicatorsElement);i&&(i.classList.add(zi),i.setAttribute("aria-current","true"))}_updateInterval(){let e=this._activeElement||this._getActive();if(!e)return;let t=Number.parseInt(e.getAttribute("data-bs-interval"),10);this._config.interval=t||this._config.defaultInterval}_slide(e,t=null){if(this._isSliding)return;let i=this._getActive(),r=e===Zn,o=t||Do(this._getItems(),i,r,this._config.wrap);if(o===i)return;let s=this._getItemIndex(o),a=y=>x.trigger(this._element,y,{relatedTarget:o,direction:this._orderToDirection(e),from:this._getItemIndex(i),to:s});if(a(Cd).defaultPrevented||!i||!o)return;let c=!!this._interval;this.pause(),this._isSliding=!0,this._setActiveIndicatorElement(s),this._activeElement=o;let u=r?Id:Nd,d=r?Rd:Hd;o.classList.add(d),ii(o),i.classList.add(u),o.classList.add(u);let p=()=>{o.classList.remove(u,d),o.classList.add(zi),i.classList.remove(zi,d,u),this._isSliding=!1,a(ho)};this._queueCallback(p,i,this._isAnimated()),c&&this.cycle()}_isAnimated(){return this._element.classList.contains(kd)}_getActive(){return j.findOne(Pd,this._element)}_getItems(){return j.find(Ca,this._element)}_clearInterval(){this._interval&&(clearInterval(this._interval),this._interval=null)}_directionToOrder(e){return Ke()?e===wn?bn:Zn:e===wn?Zn:bn}_orderToDirection(e){return Ke()?e===bn?wn:Gi:e===bn?Gi:wn}static jQueryInterface(e){return this.each(function(){let t=n.getOrCreateInstance(this,e);if(typeof e=="number"){t.to(e);return}if(typeof e=="string"){if(t[e]===void 0||e.startsWith("_")||e==="constructor")throw new TypeError(`No method named "${e}"`);t[e]()}})}};x.on(document,Md,Bd,function(n){let e=j.getElementFromSelector(this);if(!e||!e.classList.contains(xa))return;n.preventDefault();let t=ti.getOrCreateInstance(e),i=this.getAttribute("data-bs-slide-to");if(i){t.to(i),t._maybeEnableCycle();return}if(ft.getDataAttribute(this,"slide")==="next"){t.next(),t._maybeEnableCycle();return}t.prev(),t._maybeEnableCycle()});x.on(window,Ld,()=>{let n=j.find(Vd);for(let e of n)ti.getOrCreateInstance(e)});Qe(ti);var qd="collapse",Ud="bs.collapse",ri=`.${Ud}`,Yd=".data-api",Gd=`show${ri}`,Kd=`shown${ri}`,Xd=`hide${ri}`,Qd=`hidden${ri}`,Jd=`click${ri}${Yd}`,po="show",Tn="collapse",ji="collapsing",Zd="collapsed",ef=`:scope .${Tn} .${Tn}`,tf="collapse-horizontal",nf="width",rf="height",of=".collapse.show, .collapse.collapsing",Ao='[data-bs-toggle="collapse"]',sf={parent:null,toggle:!0},af={parent:"(null|element)",toggle:"boolean"},Ct=class n extends je{constructor(e,t){super(e,t),this._isTransitioning=!1,this._triggerArray=[];let i=j.find(Ao);for(let r of i){let o=j.getSelectorFromElement(r),s=j.find(o).filter(a=>a===this._element);o!==null&&s.length&&this._triggerArray.push(r)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return sf}static get DefaultType(){return af}static get NAME(){return qd}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let e=[];if(this._config.parent&&(e=this._getFirstLevelChildren(of).filter(a=>a!==this._element).map(a=>n.getOrCreateInstance(a,{toggle:!1}))),e.length&&e[0]._isTransitioning||x.trigger(this._element,Gd).defaultPrevented)return;for(let a of e)a.hide();let i=this._getDimension();this._element.classList.remove(Tn),this._element.classList.add(ji),this._element.style[i]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;let r=()=>{this._isTransitioning=!1,this._element.classList.remove(ji),this._element.classList.add(Tn,po),this._element.style[i]="",x.trigger(this._element,Kd)},s=`scroll${i[0].toUpperCase()+i.slice(1)}`;this._queueCallback(r,this._element,!0),this._element.style[i]=`${this._element[s]}px`}hide(){if(this._isTransitioning||!this._isShown()||x.trigger(this._element,Xd).defaultPrevented)return;let t=this._getDimension();this._element.style[t]=`${this._element.getBoundingClientRect()[t]}px`,ii(this._element),this._element.classList.add(ji),this._element.classList.remove(Tn,po);for(let r of this._triggerArray){let o=j.getElementFromSelector(r);o&&!this._isShown(o)&&this._addAriaAndCollapsedClass([r],!1)}this._isTransitioning=!0;let i=()=>{this._isTransitioning=!1,this._element.classList.remove(ji),this._element.classList.add(Tn),x.trigger(this._element,Qd)};this._element.style[t]="",this._queueCallback(i,this._element,!0)}_isShown(e=this._element){return e.classList.contains(po)}_configAfterMerge(e){return e.toggle=!!e.toggle,e.parent=xt(e.parent),e}_getDimension(){return this._element.classList.contains(tf)?nf:rf}_initializeChildren(){if(!this._config.parent)return;let e=this._getFirstLevelChildren(Ao);for(let t of e){let i=j.getElementFromSelector(t);i&&this._addAriaAndCollapsedClass([t],this._isShown(i))}}_getFirstLevelChildren(e){let t=j.find(ef,this._config.parent);return j.find(e,this._config.parent).filter(i=>!t.includes(i))}_addAriaAndCollapsedClass(e,t){if(e.length)for(let i of e)i.classList.toggle(Zd,!t),i.setAttribute("aria-expanded",t)}static jQueryInterface(e){let t={};return typeof e=="string"&&/show|hide/.test(e)&&(t.toggle=!1),this.each(function(){let i=n.getOrCreateInstance(this,t);if(typeof e=="string"){if(typeof i[e]=="undefined")throw new TypeError(`No method named "${e}"`);i[e]()}})}};x.on(document,Jd,Ao,function(n){(n.target.tagName==="A"||n.delegateTarget&&n.delegateTarget.tagName==="A")&&n.preventDefault();for(let e of j.getMultipleElementsFromSelector(this))Ct.getOrCreateInstance(e,{toggle:!1}).toggle()});Qe(Ct);var Bs="dropdown",lf="bs.dropdown",Jt=`.${lf}`,Lo=".data-api",cf="Escape",Vs="Tab",uf="ArrowUp",zs="ArrowDown",df=2,ff=`hide${Jt}`,hf=`hidden${Jt}`,pf=`show${Jt}`,mf=`shown${Jt}`,Aa=`click${Jt}${Lo}`,Sa=`keydown${Jt}${Lo}`,gf=`keyup${Jt}${Lo}`,xn="show",vf="dropup",yf="dropend",Ef="dropstart",bf="dropup-center",_f="dropdown-center",Gt='[data-bs-toggle="dropdown"]:not(.disabled):not(:disabled)',wf=`${Gt}.${xn}`,Ki=".dropdown-menu",xf=".navbar",Tf=".navbar-nav",Cf=".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",Af=Ke()?"top-end":"top-start",Sf=Ke()?"top-start":"top-end",Df=Ke()?"bottom-end":"bottom-start",Of=Ke()?"bottom-start":"bottom-end",Lf=Ke()?"left-start":"right-start",Mf=Ke()?"right-start":"left-start",kf="top",Nf="bottom",If={autoClose:!0,boundary:"clippingParents",display:"dynamic",offset:[0,2],popperConfig:null,reference:"toggle"},Rf={autoClose:"(boolean|string)",boundary:"(string|element)",display:"string",offset:"(array|string|function)",popperConfig:"(null|object|function)",reference:"(string|element|object)"},At=class n extends je{constructor(e,t){super(e,t),this._popper=null,this._parent=this._element.parentNode,this._menu=j.next(this._element,Ki)[0]||j.prev(this._element,Ki)[0]||j.findOne(Ki,this._parent),this._inNavbar=this._detectNavbar()}static get Default(){return If}static get DefaultType(){return Rf}static get NAME(){return Bs}toggle(){return this._isShown()?this.hide():this.show()}show(){if(Tt(this._element)||this._isShown())return;let e={relatedTarget:this._element};if(!x.trigger(this._element,pf,e).defaultPrevented){if(this._createPopper(),"ontouchstart"in document.documentElement&&!this._parent.closest(Tf))for(let i of[].concat(...document.body.children))x.on(i,"mouseover",Xi);this._element.focus(),this._element.setAttribute("aria-expanded",!0),this._menu.classList.add(xn),this._element.classList.add(xn),x.trigger(this._element,mf,e)}}hide(){if(Tt(this._element)||!this._isShown())return;let e={relatedTarget:this._element};this._completeHide(e)}dispose(){this._popper&&this._popper.destroy(),super.dispose()}update(){this._inNavbar=this._detectNavbar(),this._popper&&this._popper.update()}_completeHide(e){if(!x.trigger(this._element,ff,e).defaultPrevented){if("ontouchstart"in document.documentElement)for(let i of[].concat(...document.body.children))x.off(i,"mouseover",Xi);this._popper&&this._popper.destroy(),this._menu.classList.remove(xn),this._element.classList.remove(xn),this._element.setAttribute("aria-expanded","false"),ft.removeDataAttribute(this._menu,"popper"),x.trigger(this._element,hf,e)}}_getConfig(e){if(e=super._getConfig(e),typeof e.reference=="object"&&!dt(e.reference)&&typeof e.reference.getBoundingClientRect!="function")throw new TypeError(`${Bs.toUpperCase()}: Option "reference" provided type "object" without a required "getBoundingClientRect" method.`);return e}_createPopper(){if(typeof Vi=="undefined")throw new TypeError("Bootstrap's dropdowns require Popper (https://popper.js.org/docs/v2/)");let e=this._element;this._config.reference==="parent"?e=this._parent:dt(this._config.reference)?e=xt(this._config.reference):typeof this._config.reference=="object"&&(e=this._config.reference);let t=this._getPopperConfig();this._popper=Jn(e,this._menu,t)}_isShown(){return this._menu.classList.contains(xn)}_getPlacement(){let e=this._parent;if(e.classList.contains(yf))return Lf;if(e.classList.contains(Ef))return Mf;if(e.classList.contains(bf))return kf;if(e.classList.contains(_f))return Nf;let t=getComputedStyle(this._menu).getPropertyValue("--bs-position").trim()==="end";return e.classList.contains(vf)?t?Sf:Af:t?Of:Df}_detectNavbar(){return this._element.closest(xf)!==null}_getOffset(){let{offset:e}=this._config;return typeof e=="string"?e.split(",").map(t=>Number.parseInt(t,10)):typeof e=="function"?t=>e(t,this._element):e}_getPopperConfig(){let e={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return(this._inNavbar||this._config.display==="static")&&(ft.setDataAttribute(this._menu,"popper","static"),e.modifiers=[{name:"applyStyles",enabled:!1}]),O(O({},e),He(this._config.popperConfig,[void 0,e]))}_selectMenuItem({key:e,target:t}){let i=j.find(Cf,this._menu).filter(r=>An(r));i.length&&Do(i,t,e===zs,!i.includes(t)).focus()}static jQueryInterface(e){return this.each(function(){let t=n.getOrCreateInstance(this,e);if(typeof e=="string"){if(typeof t[e]=="undefined")throw new TypeError(`No method named "${e}"`);t[e]()}})}static clearMenus(e){if(e.button===df||e.type==="keyup"&&e.key!==Vs)return;let t=j.find(wf);for(let i of t){let r=n.getInstance(i);if(!r||r._config.autoClose===!1)continue;let o=e.composedPath(),s=o.includes(r._menu);if(o.includes(r._element)||r._config.autoClose==="inside"&&!s||r._config.autoClose==="outside"&&s||r._menu.contains(e.target)&&(e.type==="keyup"&&e.key===Vs||/input|select|option|textarea|form/i.test(e.target.tagName)))continue;let a={relatedTarget:r._element};e.type==="click"&&(a.clickEvent=e),r._completeHide(a)}}static dataApiKeydownHandler(e){let t=/input|textarea/i.test(e.target.tagName),i=e.key===cf,r=[uf,zs].includes(e.key);if(!r&&!i||t&&!i)return;e.preventDefault();let o=this.matches(Gt)?this:j.prev(this,Gt)[0]||j.next(this,Gt)[0]||j.findOne(Gt,e.delegateTarget.parentNode),s=n.getOrCreateInstance(o);if(r){e.stopPropagation(),s.show(),s._selectMenuItem(e);return}s._isShown()&&(e.stopPropagation(),s.hide(),o.focus())}};x.on(document,Sa,Gt,At.dataApiKeydownHandler);x.on(document,Sa,Ki,At.dataApiKeydownHandler);x.on(document,Aa,At.clearMenus);x.on(document,gf,At.clearMenus);x.on(document,Aa,Gt,function(n){n.preventDefault(),At.getOrCreateInstance(this).toggle()});Qe(At);var Da="backdrop",Hf="fade",js="show",Ws=`mousedown.bs.${Da}`,Pf={className:"modal-backdrop",clickCallback:null,isAnimated:!1,isVisible:!0,rootElement:"body"},Ff={className:"string",clickCallback:"(function|null)",isAnimated:"boolean",isVisible:"boolean",rootElement:"(element|string)"},er=class extends Xt{constructor(e){super(),this._config=this._getConfig(e),this._isAppended=!1,this._element=null}static get Default(){return Pf}static get DefaultType(){return Ff}static get NAME(){return Da}show(e){if(!this._config.isVisible){He(e);return}this._append();let t=this._getElement();this._config.isAnimated&&ii(t),t.classList.add(js),this._emulateAnimation(()=>{He(e)})}hide(e){if(!this._config.isVisible){He(e);return}this._getElement().classList.remove(js),this._emulateAnimation(()=>{this.dispose(),He(e)})}dispose(){this._isAppended&&(x.off(this._element,Ws),this._element.remove(),this._isAppended=!1)}_getElement(){if(!this._element){let e=document.createElement("div");e.className=this._config.className,this._config.isAnimated&&e.classList.add(Hf),this._element=e}return this._element}_configAfterMerge(e){return e.rootElement=xt(e.rootElement),e}_append(){if(this._isAppended)return;let e=this._getElement();this._config.rootElement.append(e),x.on(e,Ws,()=>{He(this._config.clickCallback)}),this._isAppended=!0}_emulateAnimation(e){pa(e,this._getElement(),this._config.isAnimated)}},$f="focustrap",Bf="bs.focustrap",tr=`.${Bf}`,Vf=`focusin${tr}`,zf=`keydown.tab${tr}`,jf="Tab",Wf="forward",qs="backward",qf={autofocus:!0,trapElement:null},Uf={autofocus:"boolean",trapElement:"element"},nr=class extends Xt{constructor(e){super(),this._config=this._getConfig(e),this._isActive=!1,this._lastTabNavDirection=null}static get Default(){return qf}static get DefaultType(){return Uf}static get NAME(){return $f}activate(){this._isActive||(this._config.autofocus&&this._config.trapElement.focus(),x.off(document,tr),x.on(document,Vf,e=>this._handleFocusin(e)),x.on(document,zf,e=>this._handleKeydown(e)),this._isActive=!0)}deactivate(){this._isActive&&(this._isActive=!1,x.off(document,tr))}_handleFocusin(e){let{trapElement:t}=this._config;if(e.target===document||e.target===t||t.contains(e.target))return;let i=j.focusableChildren(t);i.length===0?t.focus():this._lastTabNavDirection===qs?i[i.length-1].focus():i[0].focus()}_handleKeydown(e){e.key===jf&&(this._lastTabNavDirection=e.shiftKey?qs:Wf)}},Us=".fixed-top, .fixed-bottom, .is-fixed, .sticky-top",Ys=".sticky-top",Wi="padding-right",Gs="margin-right",ni=class{constructor(){this._element=document.body}getWidth(){let e=document.documentElement.clientWidth;return Math.abs(window.innerWidth-e)}hide(){let e=this.getWidth();this._disableOverFlow(),this._setElementAttributes(this._element,Wi,t=>t+e),this._setElementAttributes(Us,Wi,t=>t+e),this._setElementAttributes(Ys,Gs,t=>t-e)}reset(){this._resetElementAttributes(this._element,"overflow"),this._resetElementAttributes(this._element,Wi),this._resetElementAttributes(Us,Wi),this._resetElementAttributes(Ys,Gs)}isOverflowing(){return this.getWidth()>0}_disableOverFlow(){this._saveInitialAttribute(this._element,"overflow"),this._element.style.overflow="hidden"}_setElementAttributes(e,t,i){let r=this.getWidth(),o=s=>{if(s!==this._element&&window.innerWidth>s.clientWidth+r)return;this._saveInitialAttribute(s,t);let a=window.getComputedStyle(s).getPropertyValue(t);s.style.setProperty(t,`${i(Number.parseFloat(a))}px`)};this._applyManipulationCallback(e,o)}_saveInitialAttribute(e,t){let i=e.style.getPropertyValue(t);i&&ft.setDataAttribute(e,t,i)}_resetElementAttributes(e,t){let i=r=>{let o=ft.getDataAttribute(r,t);if(o===null){r.style.removeProperty(t);return}ft.removeDataAttribute(r,t),r.style.setProperty(t,o)};this._applyManipulationCallback(e,i)}_applyManipulationCallback(e,t){if(dt(e)){t(e);return}for(let i of j.find(e,this._element))t(i)}},Yf="modal",Gf="bs.modal",Xe=`.${Gf}`,Kf=".data-api",Xf="Escape",Qf=`hide${Xe}`,Jf=`hidePrevented${Xe}`,Oa=`hidden${Xe}`,La=`show${Xe}`,Zf=`shown${Xe}`,eh=`resize${Xe}`,th=`click.dismiss${Xe}`,nh=`mousedown.dismiss${Xe}`,ih=`keydown.dismiss${Xe}`,rh=`click${Xe}${Kf}`,Ks="modal-open",oh="fade",Xs="show",mo="modal-static",sh=".modal.show",ah=".modal-dialog",lh=".modal-body",ch='[data-bs-toggle="modal"]',uh={backdrop:!0,focus:!0,keyboard:!0},dh={backdrop:"(boolean|string)",focus:"boolean",keyboard:"boolean"},tt=class n extends je{constructor(e,t){super(e,t),this._dialog=j.findOne(ah,this._element),this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._isTransitioning=!1,this._scrollBar=new ni,this._addEventListeners()}static get Default(){return uh}static get DefaultType(){return dh}static get NAME(){return Yf}toggle(e){return this._isShown?this.hide():this.show(e)}show(e){this._isShown||this._isTransitioning||x.trigger(this._element,La,{relatedTarget:e}).defaultPrevented||(this._isShown=!0,this._isTransitioning=!0,this._scrollBar.hide(),document.body.classList.add(Ks),this._adjustDialog(),this._backdrop.show(()=>this._showElement(e)))}hide(){!this._isShown||this._isTransitioning||x.trigger(this._element,Qf).defaultPrevented||(this._isShown=!1,this._isTransitioning=!0,this._focustrap.deactivate(),this._element.classList.remove(Xs),this._queueCallback(()=>this._hideModal(),this._element,this._isAnimated()))}dispose(){x.off(window,Xe),x.off(this._dialog,Xe),this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new er({isVisible:!!this._config.backdrop,isAnimated:this._isAnimated()})}_initializeFocusTrap(){return new nr({trapElement:this._element})}_showElement(e){document.body.contains(this._element)||document.body.append(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0;let t=j.findOne(lh,this._dialog);t&&(t.scrollTop=0),ii(this._element),this._element.classList.add(Xs);let i=()=>{this._config.focus&&this._focustrap.activate(),this._isTransitioning=!1,x.trigger(this._element,Zf,{relatedTarget:e})};this._queueCallback(i,this._dialog,this._isAnimated())}_addEventListeners(){x.on(this._element,ih,e=>{if(e.key===Xf){if(this._config.keyboard){this.hide();return}this._triggerBackdropTransition()}}),x.on(window,eh,()=>{this._isShown&&!this._isTransitioning&&this._adjustDialog()}),x.on(this._element,nh,e=>{x.one(this._element,th,t=>{if(!(this._element!==e.target||this._element!==t.target)){if(this._config.backdrop==="static"){this._triggerBackdropTransition();return}this._config.backdrop&&this.hide()}})})}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide(()=>{document.body.classList.remove(Ks),this._resetAdjustments(),this._scrollBar.reset(),x.trigger(this._element,Oa)})}_isAnimated(){return this._element.classList.contains(oh)}_triggerBackdropTransition(){if(x.trigger(this._element,Jf).defaultPrevented)return;let t=this._element.scrollHeight>document.documentElement.clientHeight,i=this._element.style.overflowY;i==="hidden"||this._element.classList.contains(mo)||(t||(this._element.style.overflowY="hidden"),this._element.classList.add(mo),this._queueCallback(()=>{this._element.classList.remove(mo),this._queueCallback(()=>{this._element.style.overflowY=i},this._dialog)},this._dialog),this._element.focus())}_adjustDialog(){let e=this._element.scrollHeight>document.documentElement.clientHeight,t=this._scrollBar.getWidth(),i=t>0;if(i&&!e){let r=Ke()?"paddingLeft":"paddingRight";this._element.style[r]=`${t}px`}if(!i&&e){let r=Ke()?"paddingRight":"paddingLeft";this._element.style[r]=`${t}px`}}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(e,t){return this.each(function(){let i=n.getOrCreateInstance(this,e);if(typeof e=="string"){if(typeof i[e]=="undefined")throw new TypeError(`No method named "${e}"`);i[e](t)}})}};x.on(document,rh,ch,function(n){let e=j.getElementFromSelector(this);["A","AREA"].includes(this.tagName)&&n.preventDefault(),x.one(e,La,r=>{r.defaultPrevented||x.one(e,Oa,()=>{An(this)&&this.focus()})});let t=j.findOne(sh);t&&tt.getInstance(t).hide(),tt.getOrCreateInstance(e).toggle(this)});rr(tt);Qe(tt);var fh="offcanvas",hh="bs.offcanvas",mt=`.${hh}`,Ma=".data-api",ph=`load${mt}${Ma}`,mh="Escape",Qs="show",Js="showing",Zs="hiding",gh="offcanvas-backdrop",ka=".offcanvas.show",vh=`show${mt}`,yh=`shown${mt}`,Eh=`hide${mt}`,ea=`hidePrevented${mt}`,Na=`hidden${mt}`,bh=`resize${mt}`,_h=`click${mt}${Ma}`,wh=`keydown.dismiss${mt}`,xh='[data-bs-toggle="offcanvas"]',Th={backdrop:!0,keyboard:!0,scroll:!1},Ch={backdrop:"(boolean|string)",keyboard:"boolean",scroll:"boolean"},St=class n extends je{constructor(e,t){super(e,t),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners()}static get Default(){return Th}static get DefaultType(){return Ch}static get NAME(){return fh}toggle(e){return this._isShown?this.hide():this.show(e)}show(e){if(this._isShown||x.trigger(this._element,vh,{relatedTarget:e}).defaultPrevented)return;this._isShown=!0,this._backdrop.show(),this._config.scroll||new ni().hide(),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add(Js);let i=()=>{(!this._config.scroll||this._config.backdrop)&&this._focustrap.activate(),this._element.classList.add(Qs),this._element.classList.remove(Js),x.trigger(this._element,yh,{relatedTarget:e})};this._queueCallback(i,this._element,!0)}hide(){if(!this._isShown||x.trigger(this._element,Eh).defaultPrevented)return;this._focustrap.deactivate(),this._element.blur(),this._isShown=!1,this._element.classList.add(Zs),this._backdrop.hide();let t=()=>{this._element.classList.remove(Qs,Zs),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._config.scroll||new ni().reset(),x.trigger(this._element,Na)};this._queueCallback(t,this._element,!0)}dispose(){this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}_initializeBackDrop(){let e=()=>{if(this._config.backdrop==="static"){x.trigger(this._element,ea);return}this.hide()},t=!!this._config.backdrop;return new er({className:gh,isVisible:t,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:t?e:null})}_initializeFocusTrap(){return new nr({trapElement:this._element})}_addEventListeners(){x.on(this._element,wh,e=>{if(e.key===mh){if(this._config.keyboard){this.hide();return}x.trigger(this._element,ea)}})}static jQueryInterface(e){return this.each(function(){let t=n.getOrCreateInstance(this,e);if(typeof e=="string"){if(t[e]===void 0||e.startsWith("_")||e==="constructor")throw new TypeError(`No method named "${e}"`);t[e](this)}})}};x.on(document,_h,xh,function(n){let e=j.getElementFromSelector(this);if(["A","AREA"].includes(this.tagName)&&n.preventDefault(),Tt(this))return;x.one(e,Na,()=>{An(this)&&this.focus()});let t=j.findOne(ka);t&&t!==e&&St.getInstance(t).hide(),St.getOrCreateInstance(e).toggle(this)});x.on(window,ph,()=>{for(let n of j.find(ka))St.getOrCreateInstance(n).show()});x.on(window,bh,()=>{for(let n of j.find("[aria-modal][class*=show][class*=offcanvas-]"))getComputedStyle(n).position!=="fixed"&&St.getOrCreateInstance(n).hide()});rr(St);Qe(St);var Ah=/^aria-[\w-]*$/i,Ia={"*":["class","dir","id","lang","role",Ah],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],dd:[],div:[],dl:[],dt:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},Sh=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),Dh=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i,Oh=(n,e)=>{let t=n.nodeName.toLowerCase();return e.includes(t)?Sh.has(t)?!!Dh.test(n.nodeValue):!0:e.filter(i=>i instanceof RegExp).some(i=>i.test(t))};function Lh(n,e,t){if(!n.length)return n;if(t&&typeof t=="function")return t(n);let r=new window.DOMParser().parseFromString(n,"text/html"),o=[].concat(...r.body.querySelectorAll("*"));for(let s of o){let a=s.nodeName.toLowerCase();if(!Object.keys(e).includes(a)){s.remove();continue}let l=[].concat(...s.attributes),c=[].concat(e["*"]||[],e[a]||[]);for(let u of l)Oh(u,c)||s.removeAttribute(u.nodeName)}return r.body.innerHTML}var Mh="TemplateFactory",kh={allowList:Ia,content:{},extraClass:"",html:!1,sanitize:!0,sanitizeFn:null,template:"
"},Nh={allowList:"object",content:"object",extraClass:"(string|function)",html:"boolean",sanitize:"boolean",sanitizeFn:"(null|function)",template:"string"},Ih={entry:"(string|element|function|null)",selector:"(string|element)"},So=class extends Xt{constructor(e){super(),this._config=this._getConfig(e)}static get Default(){return kh}static get DefaultType(){return Nh}static get NAME(){return Mh}getContent(){return Object.values(this._config.content).map(e=>this._resolvePossibleFunction(e)).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(e){return this._checkContent(e),this._config.content=O(O({},this._config.content),e),this}toHtml(){let e=document.createElement("div");e.innerHTML=this._maybeSanitize(this._config.template);for(let[r,o]of Object.entries(this._config.content))this._setContent(e,o,r);let t=e.children[0],i=this._resolvePossibleFunction(this._config.extraClass);return i&&t.classList.add(...i.split(" ")),t}_typeCheckConfig(e){super._typeCheckConfig(e),this._checkContent(e.content)}_checkContent(e){for(let[t,i]of Object.entries(e))super._typeCheckConfig({selector:t,entry:i},Ih)}_setContent(e,t,i){let r=j.findOne(i,e);if(r){if(t=this._resolvePossibleFunction(t),!t){r.remove();return}if(dt(t)){this._putElementInTemplate(xt(t),r);return}if(this._config.html){r.innerHTML=this._maybeSanitize(t);return}r.textContent=t}}_maybeSanitize(e){return this._config.sanitize?Lh(e,this._config.allowList,this._config.sanitizeFn):e}_resolvePossibleFunction(e){return He(e,[void 0,this])}_putElementInTemplate(e,t){if(this._config.html){t.innerHTML="",t.append(e);return}t.textContent=e.textContent}},Rh="tooltip",Hh=new Set(["sanitize","allowList","sanitizeFn"]),go="fade",Ph="modal",qi="show",Fh=".tooltip-inner",ta=`.${Ph}`,na="hide.bs.modal",ei="hover",vo="focus",yo="click",$h="manual",Bh="hide",Vh="hidden",zh="show",jh="shown",Wh="inserted",qh="click",Uh="focusin",Yh="focusout",Gh="mouseenter",Kh="mouseleave",Xh={AUTO:"auto",TOP:"top",RIGHT:Ke()?"left":"right",BOTTOM:"bottom",LEFT:Ke()?"right":"left"},Qh={allowList:Ia,animation:!0,boundary:"clippingParents",container:!1,customClass:"",delay:0,fallbackPlacements:["top","right","bottom","left"],html:!1,offset:[0,6],placement:"top",popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:'',title:"",trigger:"hover focus"},Jh={allowList:"object",animation:"boolean",boundary:"(string|element)",container:"(string|element|boolean)",customClass:"(string|function)",delay:"(number|object)",fallbackPlacements:"array",html:"boolean",offset:"(array|string|function)",placement:"(string|function)",popperConfig:"(null|object|function)",sanitize:"boolean",sanitizeFn:"(null|function)",selector:"(string|boolean)",template:"string",title:"(string|element|function)",trigger:"string"},ht=class n extends je{constructor(e,t){if(typeof Vi=="undefined")throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org/docs/v2/)");super(e,t),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return Qh}static get DefaultType(){return Jh}static get NAME(){return Rh}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){if(this._isEnabled){if(this._isShown()){this._leave();return}this._enter()}}dispose(){clearTimeout(this._timeout),x.off(this._element.closest(ta),na,this._hideModalHandler),this._element.getAttribute("data-bs-original-title")&&this._element.setAttribute("title",this._element.getAttribute("data-bs-original-title")),this._disposePopper(),super.dispose()}show(){if(this._element.style.display==="none")throw new Error("Please use show on visible elements");if(!(this._isWithContent()&&this._isEnabled))return;let e=x.trigger(this._element,this.constructor.eventName(zh)),i=(fa(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(e.defaultPrevented||!i)return;this._disposePopper();let r=this._getTipElement();this._element.setAttribute("aria-describedby",r.getAttribute("id"));let{container:o}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(o.append(r),x.trigger(this._element,this.constructor.eventName(Wh))),this._popper=this._createPopper(r),r.classList.add(qi),"ontouchstart"in document.documentElement)for(let a of[].concat(...document.body.children))x.on(a,"mouseover",Xi);let s=()=>{x.trigger(this._element,this.constructor.eventName(jh)),this._isHovered===!1&&this._leave(),this._isHovered=!1};this._queueCallback(s,this.tip,this._isAnimated())}hide(){if(!this._isShown()||x.trigger(this._element,this.constructor.eventName(Bh)).defaultPrevented)return;if(this._getTipElement().classList.remove(qi),"ontouchstart"in document.documentElement)for(let r of[].concat(...document.body.children))x.off(r,"mouseover",Xi);this._activeTrigger[yo]=!1,this._activeTrigger[vo]=!1,this._activeTrigger[ei]=!1,this._isHovered=null;let i=()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute("aria-describedby"),x.trigger(this._element,this.constructor.eventName(Vh)))};this._queueCallback(i,this.tip,this._isAnimated())}update(){this._popper&&this._popper.update()}_isWithContent(){return!!this._getTitle()}_getTipElement(){return this.tip||(this.tip=this._createTipElement(this._newContent||this._getContentForTemplate())),this.tip}_createTipElement(e){let t=this._getTemplateFactory(e).toHtml();if(!t)return null;t.classList.remove(go,qi),t.classList.add(`bs-${this.constructor.NAME}-auto`);let i=$u(this.constructor.NAME).toString();return t.setAttribute("id",i),this._isAnimated()&&t.classList.add(go),t}setContent(e){this._newContent=e,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(e){return this._templateFactory?this._templateFactory.changeContent(e):this._templateFactory=new So(ae(O({},this._config),{content:e,extraClass:this._resolvePossibleFunction(this._config.customClass)})),this._templateFactory}_getContentForTemplate(){return{[Fh]:this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute("data-bs-original-title")}_initializeOnDelegatedTarget(e){return this.constructor.getOrCreateInstance(e.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(go)}_isShown(){return this.tip&&this.tip.classList.contains(qi)}_createPopper(e){let t=He(this._config.placement,[this,e,this._element]),i=Xh[t.toUpperCase()];return Jn(this._element,e,this._getPopperConfig(i))}_getOffset(){let{offset:e}=this._config;return typeof e=="string"?e.split(",").map(t=>Number.parseInt(t,10)):typeof e=="function"?t=>e(t,this._element):e}_resolvePossibleFunction(e){return He(e,[this._element,this._element])}_getPopperConfig(e){let t={placement:e,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"preSetPlacement",enabled:!0,phase:"beforeMain",fn:i=>{this._getTipElement().setAttribute("data-popper-placement",i.state.placement)}}]};return O(O({},t),He(this._config.popperConfig,[void 0,t]))}_setListeners(){let e=this._config.trigger.split(" ");for(let t of e)if(t==="click")x.on(this._element,this.constructor.eventName(qh),this._config.selector,i=>{let r=this._initializeOnDelegatedTarget(i);r._activeTrigger[yo]=!(r._isShown()&&r._activeTrigger[yo]),r.toggle()});else if(t!==$h){let i=t===ei?this.constructor.eventName(Gh):this.constructor.eventName(Uh),r=t===ei?this.constructor.eventName(Kh):this.constructor.eventName(Yh);x.on(this._element,i,this._config.selector,o=>{let s=this._initializeOnDelegatedTarget(o);s._activeTrigger[o.type==="focusin"?vo:ei]=!0,s._enter()}),x.on(this._element,r,this._config.selector,o=>{let s=this._initializeOnDelegatedTarget(o);s._activeTrigger[o.type==="focusout"?vo:ei]=s._element.contains(o.relatedTarget),s._leave()})}this._hideModalHandler=()=>{this._element&&this.hide()},x.on(this._element.closest(ta),na,this._hideModalHandler)}_fixTitle(){let e=this._element.getAttribute("title");e&&(!this._element.getAttribute("aria-label")&&!this._element.textContent.trim()&&this._element.setAttribute("aria-label",e),this._element.setAttribute("data-bs-original-title",e),this._element.removeAttribute("title"))}_enter(){if(this._isShown()||this._isHovered){this._isHovered=!0;return}this._isHovered=!0,this._setTimeout(()=>{this._isHovered&&this.show()},this._config.delay.show)}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout(()=>{this._isHovered||this.hide()},this._config.delay.hide))}_setTimeout(e,t){clearTimeout(this._timeout),this._timeout=setTimeout(e,t)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(e){let t=ft.getDataAttributes(this._element);for(let i of Object.keys(t))Hh.has(i)&&delete t[i];return e=O(O({},t),typeof e=="object"&&e?e:{}),e=this._mergeConfigObj(e),e=this._configAfterMerge(e),this._typeCheckConfig(e),e}_configAfterMerge(e){return e.container=e.container===!1?document.body:xt(e.container),typeof e.delay=="number"&&(e.delay={show:e.delay,hide:e.delay}),typeof e.title=="number"&&(e.title=e.title.toString()),typeof e.content=="number"&&(e.content=e.content.toString()),e}_getDelegateConfig(){let e={};for(let[t,i]of Object.entries(this._config))this.constructor.Default[t]!==i&&(e[t]=i);return e.selector=!1,e.trigger="manual",e}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null),this.tip&&(this.tip.remove(),this.tip=null)}static jQueryInterface(e){return this.each(function(){let t=n.getOrCreateInstance(this,e);if(typeof e=="string"){if(typeof t[e]=="undefined")throw new TypeError(`No method named "${e}"`);t[e]()}})}};Qe(ht);var Zh="popover",ep=".popover-header",tp=".popover-body",np=ae(O({},ht.Default),{content:"",offset:[0,8],placement:"right",template:'',trigger:"click"}),ip=ae(O({},ht.DefaultType),{content:"(null|string|element|function)"}),Cn=class n extends ht{static get Default(){return np}static get DefaultType(){return ip}static get NAME(){return Zh}_isWithContent(){return this._getTitle()||this._getContent()}_getContentForTemplate(){return{[ep]:this._getTitle(),[tp]:this._getContent()}}_getContent(){return this._resolvePossibleFunction(this._config.content)}static jQueryInterface(e){return this.each(function(){let t=n.getOrCreateInstance(this,e);if(typeof e=="string"){if(typeof t[e]=="undefined")throw new TypeError(`No method named "${e}"`);t[e]()}})}};Qe(Cn);var rp="scrollspy",op="bs.scrollspy",Mo=`.${op}`,sp=".data-api",ap=`activate${Mo}`,ia=`click${Mo}`,lp=`load${Mo}${sp}`,cp="dropdown-item",_n="active",up='[data-bs-spy="scroll"]',Eo="[href]",dp=".nav, .list-group",ra=".nav-link",fp=".nav-item",hp=".list-group-item",pp=`${ra}, ${fp} > ${ra}, ${hp}`,mp=".dropdown",gp=".dropdown-toggle",vp={offset:null,rootMargin:"0px 0px -25%",smoothScroll:!1,target:null,threshold:[.1,.5,1]},yp={offset:"(number|null)",rootMargin:"string",smoothScroll:"boolean",target:"element",threshold:"array"},ir=class n extends je{constructor(e,t){super(e,t),this._targetLinks=new Map,this._observableSections=new Map,this._rootElement=getComputedStyle(this._element).overflowY==="visible"?null:this._element,this._activeTarget=null,this._observer=null,this._previousScrollData={visibleEntryTop:0,parentScrollTop:0},this.refresh()}static get Default(){return vp}static get DefaultType(){return yp}static get NAME(){return rp}refresh(){this._initializeTargetsAndObservables(),this._maybeEnableSmoothScroll(),this._observer?this._observer.disconnect():this._observer=this._getNewObserver();for(let e of this._observableSections.values())this._observer.observe(e)}dispose(){this._observer.disconnect(),super.dispose()}_configAfterMerge(e){return e.target=xt(e.target)||document.body,e.rootMargin=e.offset?`${e.offset}px 0px -30%`:e.rootMargin,typeof e.threshold=="string"&&(e.threshold=e.threshold.split(",").map(t=>Number.parseFloat(t))),e}_maybeEnableSmoothScroll(){this._config.smoothScroll&&(x.off(this._config.target,ia),x.on(this._config.target,ia,Eo,e=>{let t=this._observableSections.get(e.target.hash);if(t){e.preventDefault();let i=this._rootElement||window,r=t.offsetTop-this._element.offsetTop;if(i.scrollTo){i.scrollTo({top:r,behavior:"smooth"});return}i.scrollTop=r}}))}_getNewObserver(){let e={root:this._rootElement,threshold:this._config.threshold,rootMargin:this._config.rootMargin};return new IntersectionObserver(t=>this._observerCallback(t),e)}_observerCallback(e){let t=s=>this._targetLinks.get(`#${s.target.id}`),i=s=>{this._previousScrollData.visibleEntryTop=s.target.offsetTop,this._process(t(s))},r=(this._rootElement||document.documentElement).scrollTop,o=r>=this._previousScrollData.parentScrollTop;this._previousScrollData.parentScrollTop=r;for(let s of e){if(!s.isIntersecting){this._activeTarget=null,this._clearActiveClass(t(s));continue}let a=s.target.offsetTop>=this._previousScrollData.visibleEntryTop;if(o&&a){if(i(s),!r)return;continue}!o&&!a&&i(s)}}_initializeTargetsAndObservables(){this._targetLinks=new Map,this._observableSections=new Map;let e=j.find(Eo,this._config.target);for(let t of e){if(!t.hash||Tt(t))continue;let i=j.findOne(decodeURI(t.hash),this._element);An(i)&&(this._targetLinks.set(decodeURI(t.hash),t),this._observableSections.set(t.hash,i))}}_process(e){this._activeTarget!==e&&(this._clearActiveClass(this._config.target),this._activeTarget=e,e.classList.add(_n),this._activateParents(e),x.trigger(this._element,ap,{relatedTarget:e}))}_activateParents(e){if(e.classList.contains(cp)){j.findOne(gp,e.closest(mp)).classList.add(_n);return}for(let t of j.parents(e,dp))for(let i of j.prev(t,pp))i.classList.add(_n)}_clearActiveClass(e){e.classList.remove(_n);let t=j.find(`${Eo}.${_n}`,e);for(let i of t)i.classList.remove(_n)}static jQueryInterface(e){return this.each(function(){let t=n.getOrCreateInstance(this,e);if(typeof e=="string"){if(t[e]===void 0||e.startsWith("_")||e==="constructor")throw new TypeError(`No method named "${e}"`);t[e]()}})}};x.on(window,lp,()=>{for(let n of j.find(up))ir.getOrCreateInstance(n)});Qe(ir);var Ep="tab",bp="bs.tab",Zt=`.${bp}`,_p=`hide${Zt}`,wp=`hidden${Zt}`,xp=`show${Zt}`,Tp=`shown${Zt}`,Cp=`click${Zt}`,Ap=`keydown${Zt}`,Sp=`load${Zt}`,Dp="ArrowLeft",oa="ArrowRight",Op="ArrowUp",sa="ArrowDown",bo="Home",aa="End",Kt="active",la="fade",_o="show",Lp="dropdown",Ra=".dropdown-toggle",Mp=".dropdown-menu",wo=`:not(${Ra})`,kp='.list-group, .nav, [role="tablist"]',Np=".nav-item, .list-group-item",Ip=`.nav-link${wo}, .list-group-item${wo}, [role="tab"]${wo}`,Ha='[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',xo=`${Ip}, ${Ha}`,Rp=`.${Kt}[data-bs-toggle="tab"], .${Kt}[data-bs-toggle="pill"], .${Kt}[data-bs-toggle="list"]`,Qt=class n extends je{constructor(e){super(e),this._parent=this._element.closest(kp),this._parent&&(this._setInitialAttributes(this._parent,this._getChildren()),x.on(this._element,Ap,t=>this._keydown(t)))}static get NAME(){return Ep}show(){let e=this._element;if(this._elemIsActive(e))return;let t=this._getActiveElem(),i=t?x.trigger(t,_p,{relatedTarget:e}):null;x.trigger(e,xp,{relatedTarget:t}).defaultPrevented||i&&i.defaultPrevented||(this._deactivate(t,e),this._activate(e,t))}_activate(e,t){if(!e)return;e.classList.add(Kt),this._activate(j.getElementFromSelector(e));let i=()=>{if(e.getAttribute("role")!=="tab"){e.classList.add(_o);return}e.removeAttribute("tabindex"),e.setAttribute("aria-selected",!0),this._toggleDropDown(e,!0),x.trigger(e,Tp,{relatedTarget:t})};this._queueCallback(i,e,e.classList.contains(la))}_deactivate(e,t){if(!e)return;e.classList.remove(Kt),e.blur(),this._deactivate(j.getElementFromSelector(e));let i=()=>{if(e.getAttribute("role")!=="tab"){e.classList.remove(_o);return}e.setAttribute("aria-selected",!1),e.setAttribute("tabindex","-1"),this._toggleDropDown(e,!1),x.trigger(e,wp,{relatedTarget:t})};this._queueCallback(i,e,e.classList.contains(la))}_keydown(e){if(![Dp,oa,Op,sa,bo,aa].includes(e.key))return;e.stopPropagation(),e.preventDefault();let t=this._getChildren().filter(r=>!Tt(r)),i;if([bo,aa].includes(e.key))i=t[e.key===bo?0:t.length-1];else{let r=[oa,sa].includes(e.key);i=Do(t,e.target,r,!0)}i&&(i.focus({preventScroll:!0}),n.getOrCreateInstance(i).show())}_getChildren(){return j.find(xo,this._parent)}_getActiveElem(){return this._getChildren().find(e=>this._elemIsActive(e))||null}_setInitialAttributes(e,t){this._setAttributeIfNotExists(e,"role","tablist");for(let i of t)this._setInitialAttributesOnChild(i)}_setInitialAttributesOnChild(e){e=this._getInnerElement(e);let t=this._elemIsActive(e),i=this._getOuterElement(e);e.setAttribute("aria-selected",t),i!==e&&this._setAttributeIfNotExists(i,"role","presentation"),t||e.setAttribute("tabindex","-1"),this._setAttributeIfNotExists(e,"role","tab"),this._setInitialAttributesOnTargetPanel(e)}_setInitialAttributesOnTargetPanel(e){let t=j.getElementFromSelector(e);t&&(this._setAttributeIfNotExists(t,"role","tabpanel"),e.id&&this._setAttributeIfNotExists(t,"aria-labelledby",`${e.id}`))}_toggleDropDown(e,t){let i=this._getOuterElement(e);if(!i.classList.contains(Lp))return;let r=(o,s)=>{let a=j.findOne(o,i);a&&a.classList.toggle(s,t)};r(Ra,Kt),r(Mp,_o),i.setAttribute("aria-expanded",t)}_setAttributeIfNotExists(e,t,i){e.hasAttribute(t)||e.setAttribute(t,i)}_elemIsActive(e){return e.classList.contains(Kt)}_getInnerElement(e){return e.matches(xo)?e:j.findOne(xo,e)}_getOuterElement(e){return e.closest(Np)||e}static jQueryInterface(e){return this.each(function(){let t=n.getOrCreateInstance(this);if(typeof e=="string"){if(t[e]===void 0||e.startsWith("_")||e==="constructor")throw new TypeError(`No method named "${e}"`);t[e]()}})}};x.on(document,Cp,Ha,function(n){["A","AREA"].includes(this.tagName)&&n.preventDefault(),!Tt(this)&&Qt.getOrCreateInstance(this).show()});x.on(window,Sp,()=>{for(let n of j.find(Rp))Qt.getOrCreateInstance(n)});Qe(Qt);var Hp="toast",Pp="bs.toast",Ot=`.${Pp}`,Fp=`mouseover${Ot}`,$p=`mouseout${Ot}`,Bp=`focusin${Ot}`,Vp=`focusout${Ot}`,zp=`hide${Ot}`,jp=`hidden${Ot}`,Wp=`show${Ot}`,qp=`shown${Ot}`,Up="fade",ca="hide",Ui="show",Yi="showing",Yp={animation:"boolean",autohide:"boolean",delay:"number"},Gp={animation:!0,autohide:!0,delay:5e3},pt=class n extends je{constructor(e,t){super(e,t),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get Default(){return Gp}static get DefaultType(){return Yp}static get NAME(){return Hp}show(){if(x.trigger(this._element,Wp).defaultPrevented)return;this._clearTimeout(),this._config.animation&&this._element.classList.add(Up);let t=()=>{this._element.classList.remove(Yi),x.trigger(this._element,qp),this._maybeScheduleHide()};this._element.classList.remove(ca),ii(this._element),this._element.classList.add(Ui,Yi),this._queueCallback(t,this._element,this._config.animation)}hide(){if(!this.isShown()||x.trigger(this._element,zp).defaultPrevented)return;let t=()=>{this._element.classList.add(ca),this._element.classList.remove(Yi,Ui),x.trigger(this._element,jp)};this._element.classList.add(Yi),this._queueCallback(t,this._element,this._config.animation)}dispose(){this._clearTimeout(),this.isShown()&&this._element.classList.remove(Ui),super.dispose()}isShown(){return this._element.classList.contains(Ui)}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout(()=>{this.hide()},this._config.delay)))}_onInteraction(e,t){switch(e.type){case"mouseover":case"mouseout":{this._hasMouseInteraction=t;break}case"focusin":case"focusout":{this._hasKeyboardInteraction=t;break}}if(t){this._clearTimeout();return}let i=e.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){x.on(this._element,Fp,e=>this._onInteraction(e,!0)),x.on(this._element,$p,e=>this._onInteraction(e,!1)),x.on(this._element,Bp,e=>this._onInteraction(e,!0)),x.on(this._element,Vp,e=>this._onInteraction(e,!1))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(e){return this.each(function(){let t=n.getOrCreateInstance(this,e);if(typeof e=="string"){if(typeof t[e]=="undefined")throw new TypeError(`No method named "${e}"`);t[e](this)}})}};rr(pt);Qe(pt);var Kp=(function(){"use strict";let htmx={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(n,e){return getInputValues(n,e||"post").values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:!0,historyCacheSize:10,refreshOnHistoryMiss:!1,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:!0,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:!0,allowScriptTags:!0,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:!1,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:!1,getCacheBusterParam:!1,globalViewTransitions:!1,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:!0,ignoreTitle:!1,scrollIntoViewOnBoost:!0,triggerSpecsCache:null,disableInheritance:!1,responseHandling:[{code:"204",swap:!1},{code:"[23]..",swap:!0},{code:"[45]..",swap:!1,error:!0}],allowNestedOobSwaps:!0,historyRestoreAsHxRequest:!0,reportValidityOfForms:!1},parseInterval:null,location,_:null,version:"2.0.8"};htmx.onLoad=onLoadHelper,htmx.process=processNode,htmx.on=addEventListenerImpl,htmx.off=removeEventListenerImpl,htmx.trigger=triggerEvent,htmx.ajax=ajaxHelper,htmx.find=find,htmx.findAll=findAll,htmx.closest=closest,htmx.remove=removeElement,htmx.addClass=addClassToElement,htmx.removeClass=removeClassFromElement,htmx.toggleClass=toggleClassOnElement,htmx.takeClass=takeClassForElement,htmx.swap=swap,htmx.defineExtension=defineExtension,htmx.removeExtension=removeExtension,htmx.logAll=logAll,htmx.logNone=logNone,htmx.parseInterval=parseInterval,htmx._=internalEval;let internalAPI={addTriggerHandler,bodyContains,canAccessLocalStorage,findThisElement,filterValues,swap,hasAttribute,getAttributeValue,getClosestAttributeValue,getClosestMatch,getExpressionVars,getHeaders,getInputValues,getInternalData,getSwapSpecification,getTriggerSpecs,getTarget,makeFragment,mergeObjects,makeSettleInfo,oobSwap,querySelectorExt,settleImmediately,shouldCancel,triggerEvent,triggerErrorEvent,withExtensions},VERBS=["get","post","put","delete","patch"],VERB_SELECTOR=VERBS.map(function(n){return"[hx-"+n+"], [data-hx-"+n+"]"}).join(", ");function parseInterval(n){if(n==null)return;let e=NaN;return n.slice(-2)=="ms"?e=parseFloat(n.slice(0,-2)):n.slice(-1)=="s"?e=parseFloat(n.slice(0,-1))*1e3:n.slice(-1)=="m"?e=parseFloat(n.slice(0,-1))*1e3*60:e=parseFloat(n),isNaN(e)?void 0:e}function getRawAttribute(n,e){return n instanceof Element&&n.getAttribute(e)}function hasAttribute(n,e){return!!n.hasAttribute&&(n.hasAttribute(e)||n.hasAttribute("data-"+e))}function getAttributeValue(n,e){return getRawAttribute(n,e)||getRawAttribute(n,"data-"+e)}function parentElt(n){let e=n.parentElement;return!e&&n.parentNode instanceof ShadowRoot?n.parentNode:e}function getDocument(){return document}function getRootNode(n,e){return n.getRootNode?n.getRootNode({composed:e}):getDocument()}function getClosestMatch(n,e){for(;n&&!e(n);)n=parentElt(n);return n||null}function getAttributeValueWithDisinheritance(n,e,t){let i=getAttributeValue(e,t),r=getAttributeValue(e,"hx-disinherit");var o=getAttributeValue(e,"hx-inherit");if(n!==e){if(htmx.config.disableInheritance)return o&&(o==="*"||o.split(" ").indexOf(t)>=0)?i:null;if(r&&(r==="*"||r.split(" ").indexOf(t)>=0))return"unset"}return i}function getClosestAttributeValue(n,e){let t=null;if(getClosestMatch(n,function(i){return!!(t=getAttributeValueWithDisinheritance(n,asElement(i),e))}),t!=="unset")return t}function matches(n,e){return n instanceof Element&&n.matches(e)}function getStartTag(n){let t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i.exec(n);return t?t[1].toLowerCase():""}function parseHTML(n){return"parseHTMLUnsafe"in Document?Document.parseHTMLUnsafe(n):new DOMParser().parseFromString(n,"text/html")}function takeChildrenFor(n,e){for(;e.childNodes.length>0;)n.append(e.childNodes[0])}function duplicateScript(n){let e=getDocument().createElement("script");return forEach(n.attributes,function(t){e.setAttribute(t.name,t.value)}),e.textContent=n.textContent,e.async=!1,htmx.config.inlineScriptNonce&&(e.nonce=htmx.config.inlineScriptNonce),e}function isJavaScriptScriptNode(n){return n.matches("script")&&(n.type==="text/javascript"||n.type==="module"||n.type==="")}function normalizeScriptTags(n){Array.from(n.querySelectorAll("script")).forEach(e=>{if(isJavaScriptScriptNode(e)){let t=duplicateScript(e),i=e.parentNode;try{i.insertBefore(t,e)}catch(r){logError(r)}finally{e.remove()}}})}function makeFragment(n){let e=n.replace(/]*)?>[\s\S]*?<\/head>/i,""),t=getStartTag(e),i;if(t==="html"){i=new DocumentFragment;let o=parseHTML(n);takeChildrenFor(i,o.body),i.title=o.title}else if(t==="body"){i=new DocumentFragment;let o=parseHTML(e);takeChildrenFor(i,o.body),i.title=o.title}else{let o=parseHTML('");i=o.querySelector("template").content,i.title=o.title;var r=i.querySelector("title");r&&r.parentNode===i&&(r.remove(),i.title=r.innerText)}return i&&(htmx.config.allowScriptTags?normalizeScriptTags(i):i.querySelectorAll("script").forEach(o=>o.remove())),i}function maybeCall(n){n&&n()}function isType(n,e){return Object.prototype.toString.call(n)==="[object "+e+"]"}function isFunction(n){return typeof n=="function"}function isRawObject(n){return isType(n,"Object")}function getInternalData(n){let e="htmx-internal-data",t=n[e];return t||(t=n[e]={}),t}function toArray(n){let e=[];if(n)for(let t=0;t=0}function bodyContains(n){return n.getRootNode({composed:!0})===document}function splitOnWhitespace(n){return n.trim().split(/\s+/)}function mergeObjects(n,e){for(let t in e)e.hasOwnProperty(t)&&(n[t]=e[t]);return n}function parseJSON(n){try{return JSON.parse(n)}catch(e){return logError(e),null}}function canAccessLocalStorage(){let n="htmx:sessionStorageTest";try{return sessionStorage.setItem(n,n),sessionStorage.removeItem(n),!0}catch(e){return!1}}function normalizePath(n){let e=new URL(n,"http://x");return e&&(n=e.pathname+e.search),n!="/"&&(n=n.replace(/\/+$/,"")),n}function internalEval(str){return maybeEval(getDocument().body,function(){return eval(str)})}function onLoadHelper(n){return htmx.on("htmx:load",function(t){n(t.detail.elt)})}function logAll(){htmx.logger=function(n,e,t){console&&console.log(e,n,t)}}function logNone(){htmx.logger=null}function find(n,e){return typeof n!="string"?n.querySelector(e):find(getDocument(),n)}function findAll(n,e){return typeof n!="string"?n.querySelectorAll(e):findAll(getDocument(),n)}function getWindow(){return window}function removeElement(n,e){n=resolveTarget(n),e?getWindow().setTimeout(function(){removeElement(n),n=null},e):parentElt(n).removeChild(n)}function asElement(n){return n instanceof Element?n:null}function asHtmlElement(n){return n instanceof HTMLElement?n:null}function asString(n){return typeof n=="string"?n:null}function asParentNode(n){return n instanceof Element||n instanceof Document||n instanceof DocumentFragment?n:null}function addClassToElement(n,e,t){n=asElement(resolveTarget(n)),n&&(t?getWindow().setTimeout(function(){addClassToElement(n,e),n=null},t):n.classList&&n.classList.add(e))}function removeClassFromElement(n,e,t){let i=asElement(resolveTarget(n));i&&(t?getWindow().setTimeout(function(){removeClassFromElement(i,e),i=null},t):i.classList&&(i.classList.remove(e),i.classList.length===0&&i.removeAttribute("class")))}function toggleClassOnElement(n,e){n=resolveTarget(n),n.classList.toggle(e)}function takeClassForElement(n,e){n=resolveTarget(n),forEach(n.parentElement.children,function(t){removeClassFromElement(t,e)}),addClassToElement(asElement(n),e)}function closest(n,e){return n=asElement(resolveTarget(n)),n?n.closest(e):null}function startsWith(n,e){return n.substring(0,e.length)===e}function endsWith(n,e){return n.substring(n.length-e.length)===e}function normalizeSelector(n){let e=n.trim();return startsWith(e,"<")&&endsWith(e,"/>")?e.substring(1,e.length-2):e}function querySelectorAllExt(n,e,t){if(e.indexOf("global ")===0)return querySelectorAllExt(n,e.slice(7),!0);n=resolveTarget(n);let i=[];{let s=0,a=0;for(let l=0;l"&&s--}a0;){let s=normalizeSelector(i.shift()),a;s.indexOf("closest ")===0?a=closest(asElement(n),normalizeSelector(s.slice(8))):s.indexOf("find ")===0?a=find(asParentNode(n),normalizeSelector(s.slice(5))):s==="next"||s==="nextElementSibling"?a=asElement(n).nextElementSibling:s.indexOf("next ")===0?a=scanForwardQuery(n,normalizeSelector(s.slice(5)),!!t):s==="previous"||s==="previousElementSibling"?a=asElement(n).previousElementSibling:s.indexOf("previous ")===0?a=scanBackwardsQuery(n,normalizeSelector(s.slice(9)),!!t):s==="document"?a=document:s==="window"?a=window:s==="body"?a=document.body:s==="root"?a=getRootNode(n,!!t):s==="host"?a=n.getRootNode().host:o.push(s),a&&r.push(a)}if(o.length>0){let s=o.join(","),a=asParentNode(getRootNode(n,!!t));r.push(...toArray(a.querySelectorAll(s)))}return r}var scanForwardQuery=function(n,e,t){let i=asParentNode(getRootNode(n,t)).querySelectorAll(e);for(let r=0;r=0;r--){let o=i[r];if(o.compareDocumentPosition(n)===Node.DOCUMENT_POSITION_FOLLOWING)return o}};function querySelectorExt(n,e){return typeof n!="string"?querySelectorAllExt(n,e)[0]:querySelectorAllExt(getDocument().body,n)[0]}function resolveTarget(n,e){return typeof n=="string"?find(asParentNode(e)||document,n):n}function processEventArgs(n,e,t,i){return isFunction(e)?{target:getDocument().body,event:asString(n),listener:e,options:t}:{target:resolveTarget(n),event:asString(e),listener:t,options:i}}function addEventListenerImpl(n,e,t,i){return ready(function(){let o=processEventArgs(n,e,t,i);o.target.addEventListener(o.event,o.listener,o.options)}),isFunction(e)?e:t}function removeEventListenerImpl(n,e,t){return ready(function(){let i=processEventArgs(n,e,t);i.target.removeEventListener(i.event,i.listener)}),isFunction(e)?e:t}let DUMMY_ELT=getDocument().createElement("output");function findAttributeTargets(n,e){let t=getClosestAttributeValue(n,e);if(t){if(t==="this")return[findThisElement(n,e)];{let i=querySelectorAllExt(n,t);if(/(^|,)(\s*)inherit(\s*)($|,)/.test(t)){let o=asElement(getClosestMatch(n,function(s){return s!==n&&hasAttribute(asElement(s),e)}));o&&i.push(...findAttributeTargets(o,e))}return i.length===0?(logError('The selector "'+t+'" on '+e+" returned no matches!"),[DUMMY_ELT]):i}}}function findThisElement(n,e){return asElement(getClosestMatch(n,function(t){return getAttributeValue(asElement(t),e)!=null}))}function getTarget(n){let e=getClosestAttributeValue(n,"hx-target");return e?e==="this"?findThisElement(n,"hx-target"):querySelectorExt(n,e):getInternalData(n).boosted?getDocument().body:n}function shouldSettleAttribute(n){return htmx.config.attributesToSettle.includes(n)}function cloneAttributes(n,e){forEach(Array.from(n.attributes),function(t){!e.hasAttribute(t.name)&&shouldSettleAttribute(t.name)&&n.removeAttribute(t.name)}),forEach(e.attributes,function(t){shouldSettleAttribute(t.name)&&n.setAttribute(t.name,t.value)})}function isInlineSwap(n,e){let t=getExtensions(e);for(let i=0;i0?(o=n.substring(0,n.indexOf(":")),r=n.substring(n.indexOf(":")+1)):o=n),e.removeAttribute("hx-swap-oob"),e.removeAttribute("data-hx-swap-oob");let s=querySelectorAllExt(i,r,!1);return s.length?(forEach(s,function(a){let l,c=e.cloneNode(!0);l=getDocument().createDocumentFragment(),l.appendChild(c),isInlineSwap(o,a)||(l=asParentNode(c));let u={shouldSwap:!0,target:a,fragment:l};triggerEvent(a,"htmx:oobBeforeSwap",u)&&(a=u.target,u.shouldSwap&&(handlePreservedElements(l),swapWithStyle(o,a,a,l,t),restorePreservedElements()),forEach(t.elts,function(d){triggerEvent(d,"htmx:oobAfterSwap",u)}))}),e.parentNode.removeChild(e)):(e.parentNode.removeChild(e),triggerErrorEvent(getDocument().body,"htmx:oobErrorNoTarget",{content:e})),n}function restorePreservedElements(){let n=find("#--htmx-preserve-pantry--");if(n){for(let e of[...n.children]){let t=find("#"+e.id);t.parentNode.moveBefore(e,t),t.remove()}n.remove()}}function handlePreservedElements(n){forEach(findAll(n,"[hx-preserve], [data-hx-preserve]"),function(e){let t=getAttributeValue(e,"id"),i=getDocument().getElementById(t);if(i!=null)if(e.moveBefore){let r=find("#--htmx-preserve-pantry--");r==null&&(getDocument().body.insertAdjacentHTML("afterend","
"),r=find("#--htmx-preserve-pantry--")),r.moveBefore(i,null)}else e.parentNode.replaceChild(i,e)})}function handleAttributes(n,e,t){forEach(e.querySelectorAll("[id]"),function(i){let r=getRawAttribute(i,"id");if(r&&r.length>0){let o=r.replace("'","\\'"),s=i.tagName.replace(":","\\:"),a=asParentNode(n),l=a&&a.querySelector(s+"[id='"+o+"']");if(l&&l!==a){let c=i.cloneNode();cloneAttributes(i,l),t.tasks.push(function(){cloneAttributes(i,c)})}}})}function makeAjaxLoadTask(n){return function(){removeClassFromElement(n,htmx.config.addedClass),processNode(asElement(n)),processFocus(asParentNode(n)),triggerEvent(n,"htmx:load")}}function processFocus(n){let e="[autofocus]",t=asHtmlElement(matches(n,e)?n:n.querySelector(e));t!=null&&t.focus()}function insertNodesBefore(n,e,t,i){for(handleAttributes(n,t,i);t.childNodes.length>0;){let r=t.firstChild;addClassToElement(asElement(r),htmx.config.addedClass),n.insertBefore(r,e),r.nodeType!==Node.TEXT_NODE&&r.nodeType!==Node.COMMENT_NODE&&i.tasks.push(makeAjaxLoadTask(r))}}function stringHash(n,e){let t=0;for(;t0}function swap(n,e,t,i){i||(i={});let r=null,o=null,s=function(){maybeCall(i.beforeSwapCallback),n=resolveTarget(n);let c=i.contextElement?getRootNode(i.contextElement,!1):getDocument(),u=document.activeElement,d={};d={elt:u,start:u?u.selectionStart:null,end:u?u.selectionEnd:null};let p=makeSettleInfo(n);if(t.swapStyle==="textContent")n.textContent=e;else{let m=makeFragment(e);if(p.title=i.title||m.title,i.historyRequest&&(m=m.querySelector("[hx-history-elt],[data-hx-history-elt]")||m),i.selectOOB){let v=i.selectOOB.split(",");for(let w=0;w0?getWindow().setTimeout(y,t.settleDelay):y()},a=htmx.config.globalViewTransitions;t.hasOwnProperty("transition")&&(a=t.transition);let l=i.contextElement||getDocument();if(a&&triggerEvent(l,"htmx:beforeTransition",i.eventInfo)&&typeof Promise!="undefined"&&document.startViewTransition){let c=new Promise(function(d,p){r=d,o=p}),u=s;s=function(){document.startViewTransition(function(){return u(),c})}}try{t!=null&&t.swapDelay&&t.swapDelay>0?getWindow().setTimeout(s,t.swapDelay):s()}catch(c){throw triggerErrorEvent(l,"htmx:swapError",i.eventInfo),maybeCall(o),c}}function handleTriggerHeader(n,e,t){let i=n.getResponseHeader(e);if(i.indexOf("{")===0){let r=parseJSON(i);for(let o in r)if(r.hasOwnProperty(o)){let s=r[o];isRawObject(s)?t=s.target!==void 0?s.target:t:s={value:s},triggerEvent(t,o,s)}}else{let r=i.split(",");for(let o=0;o0;){let s=e[0];if(s==="]"){if(i--,i===0){o===null&&(r=r+"true"),e.shift(),r+=")})";try{let a=maybeEval(n,function(){return Function(r)()},function(){return!0});return a.source=r,a}catch(a){return triggerErrorEvent(getDocument().body,"htmx:syntax:error",{error:a,source:r}),null}}}else s==="["&&i++;isPossibleRelativeReference(s,o,t)?r+="(("+t+"."+s+") ? ("+t+"."+s+") : (window."+s+"))":r=r+s,o=e.shift()}}}function consumeUntil(n,e){let t="";for(;n.length>0&&!e.test(n[0]);)t+=n.shift();return t}function consumeCSSSelector(n){let e;return n.length>0&&COMBINED_SELECTOR_START.test(n[0])?(n.shift(),e=consumeUntil(n,COMBINED_SELECTOR_END).trim(),n.shift()):e=consumeUntil(n,WHITESPACE_OR_COMMA),e}let INPUT_SELECTOR="input, textarea, select";function parseAndCacheTrigger(n,e,t){let i=[],r=tokenizeString(e);do{consumeUntil(r,NOT_WHITESPACE);let a=r.length,l=consumeUntil(r,/[,\[\s]/);if(l!=="")if(l==="every"){let c={trigger:"every"};consumeUntil(r,NOT_WHITESPACE),c.pollInterval=parseInterval(consumeUntil(r,/[,\[\s]/)),consumeUntil(r,NOT_WHITESPACE);var o=maybeGenerateConditional(n,r,"event");o&&(c.eventFilter=o),i.push(c)}else{let c={trigger:l};var o=maybeGenerateConditional(n,r,"event");for(o&&(c.eventFilter=o),consumeUntil(r,NOT_WHITESPACE);r.length>0&&r[0]!==",";){let d=r.shift();if(d==="changed")c.changed=!0;else if(d==="once")c.once=!0;else if(d==="consume")c.consume=!0;else if(d==="delay"&&r[0]===":")r.shift(),c.delay=parseInterval(consumeUntil(r,WHITESPACE_OR_COMMA));else if(d==="from"&&r[0]===":"){if(r.shift(),COMBINED_SELECTOR_START.test(r[0]))var s=consumeCSSSelector(r);else{var s=consumeUntil(r,WHITESPACE_OR_COMMA);if(s==="closest"||s==="find"||s==="next"||s==="previous"){r.shift();let y=consumeCSSSelector(r);y.length>0&&(s+=" "+y)}}c.from=s}else d==="target"&&r[0]===":"?(r.shift(),c.target=consumeCSSSelector(r)):d==="throttle"&&r[0]===":"?(r.shift(),c.throttle=parseInterval(consumeUntil(r,WHITESPACE_OR_COMMA))):d==="queue"&&r[0]===":"?(r.shift(),c.queue=consumeUntil(r,WHITESPACE_OR_COMMA)):d==="root"&&r[0]===":"?(r.shift(),c[d]=consumeCSSSelector(r)):d==="threshold"&&r[0]===":"?(r.shift(),c[d]=consumeUntil(r,WHITESPACE_OR_COMMA)):triggerErrorEvent(n,"htmx:syntax:error",{token:r.shift()});consumeUntil(r,NOT_WHITESPACE)}i.push(c)}r.length===a&&triggerErrorEvent(n,"htmx:syntax:error",{token:r.shift()}),consumeUntil(r,NOT_WHITESPACE)}while(r[0]===","&&r.shift());return t&&(t[e]=i),i}function getTriggerSpecs(n){let e=getAttributeValue(n,"hx-trigger"),t=[];if(e){let i=htmx.config.triggerSpecsCache;t=i&&i[e]||parseAndCacheTrigger(n,e,i)}return t.length>0?t:matches(n,"form")?[{trigger:"submit"}]:matches(n,'input[type="button"], input[type="submit"]')?[{trigger:"click"}]:matches(n,INPUT_SELECTOR)?[{trigger:"change"}]:[{trigger:"click"}]}function cancelPolling(n){getInternalData(n).cancelled=!0}function processPolling(n,e,t){let i=getInternalData(n);i.timeout=getWindow().setTimeout(function(){bodyContains(n)&&i.cancelled!==!0&&(maybeFilterEvent(t,n,makeEvent("hx:poll:trigger",{triggerSpec:t,target:n}))||e(n),processPolling(n,e,t))},t.pollInterval)}function isLocalLink(n){return location.hostname===n.hostname&&getRawAttribute(n,"href")&&getRawAttribute(n,"href").indexOf("#")!==0}function eltIsDisabled(n){return closest(n,htmx.config.disableSelector)}function boostElement(n,e,t){if(n instanceof HTMLAnchorElement&&isLocalLink(n)&&(n.target===""||n.target==="_self")||n.tagName==="FORM"&&String(getRawAttribute(n,"method")).toLowerCase()!=="dialog"){e.boosted=!0;let i,r;if(n.tagName==="A")i="get",r=getRawAttribute(n,"href");else{let o=getRawAttribute(n,"method");i=o?o.toLowerCase():"get",r=getRawAttribute(n,"action"),(r==null||r==="")&&(r=location.href),i==="get"&&r.includes("?")&&(r=r.replace(/\?[^#]+/,""))}t.forEach(function(o){addEventListener(n,function(s,a){let l=asElement(s);if(eltIsDisabled(l)){cleanUpElement(l);return}issueAjaxRequest(i,r,l,a)},e,o,!0)})}}function shouldCancel(n,e){if(n.type==="submit"&&e.tagName==="FORM")return!0;if(n.type==="click"){let t=e.closest('input[type="submit"], button');if(t&&t.form&&t.type==="submit")return!0;let i=e.closest("a"),r=/^#.+/;if(i&&i.href&&!r.test(i.getAttribute("href")))return!0}return!1}function ignoreBoostedAnchorCtrlClick(n,e){return getInternalData(n).boosted&&n instanceof HTMLAnchorElement&&e.type==="click"&&(e.ctrlKey||e.metaKey)}function maybeFilterEvent(n,e,t){let i=n.eventFilter;if(i)try{return i.call(e,t)!==!0}catch(r){let o=i.source;return triggerErrorEvent(getDocument().body,"htmx:eventFilter:error",{error:r,source:o}),!0}return!1}function addEventListener(n,e,t,i,r){let o=getInternalData(n),s;i.from?s=querySelectorAllExt(n,i.from):s=[n],i.changed&&("lastValue"in o||(o.lastValue=new WeakMap),s.forEach(function(a){o.lastValue.has(i)||o.lastValue.set(i,new WeakMap),o.lastValue.get(i).set(a,a.value)})),forEach(s,function(a){let l=function(c){if(!bodyContains(n)){a.removeEventListener(i.trigger,l);return}if(ignoreBoostedAnchorCtrlClick(n,c)||((r||shouldCancel(c,a))&&c.preventDefault(),maybeFilterEvent(i,n,c)))return;let u=getInternalData(c);if(u.triggerSpec=i,u.handledFor==null&&(u.handledFor=[]),u.handledFor.indexOf(n)<0){if(u.handledFor.push(n),i.consume&&c.stopPropagation(),i.target&&c.target&&!matches(asElement(c.target),i.target))return;if(i.once){if(o.triggeredOnce)return;o.triggeredOnce=!0}if(i.changed){let d=c.target,p=d.value,y=o.lastValue.get(i);if(y.has(d)&&y.get(d)===p)return;y.set(d,p)}if(o.delayed&&clearTimeout(o.delayed),o.throttle)return;i.throttle>0?o.throttle||(triggerEvent(n,"htmx:trigger"),e(n,c),o.throttle=getWindow().setTimeout(function(){o.throttle=null},i.throttle)):i.delay>0?o.delayed=getWindow().setTimeout(function(){triggerEvent(n,"htmx:trigger"),e(n,c)},i.delay):(triggerEvent(n,"htmx:trigger"),e(n,c))}};t.listenerInfos==null&&(t.listenerInfos=[]),t.listenerInfos.push({trigger:i.trigger,listener:l,on:a}),a.addEventListener(i.trigger,l)})}let windowIsScrolling=!1,scrollHandler=null;function initScrollHandler(){scrollHandler||(scrollHandler=function(){windowIsScrolling=!0},window.addEventListener("scroll",scrollHandler),window.addEventListener("resize",scrollHandler),setInterval(function(){windowIsScrolling&&(windowIsScrolling=!1,forEach(getDocument().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(n){maybeReveal(n)}))},200))}function maybeReveal(n){!hasAttribute(n,"data-hx-revealed")&&isScrolledIntoView(n)&&(n.setAttribute("data-hx-revealed","true"),getInternalData(n).initHash?triggerEvent(n,"revealed"):n.addEventListener("htmx:afterProcessNode",function(){triggerEvent(n,"revealed")},{once:!0}))}function loadImmediately(n,e,t,i){let r=function(){t.loaded||(t.loaded=!0,triggerEvent(n,"htmx:trigger"),e(n))};i>0?getWindow().setTimeout(r,i):r()}function processVerbs(n,e,t){let i=!1;return forEach(VERBS,function(r){if(hasAttribute(n,"hx-"+r)){let o=getAttributeValue(n,"hx-"+r);i=!0,e.path=o,e.verb=r,t.forEach(function(s){addTriggerHandler(n,s,e,function(a,l){let c=asElement(a);if(eltIsDisabled(c)){cleanUpElement(c);return}issueAjaxRequest(r,o,c,l)})})}}),i}function addTriggerHandler(n,e,t,i){if(e.trigger==="revealed")initScrollHandler(),addEventListener(n,i,t,e),maybeReveal(asElement(n));else if(e.trigger==="intersect"){let r={};e.root&&(r.root=querySelectorExt(n,e.root)),e.threshold&&(r.threshold=parseFloat(e.threshold)),new IntersectionObserver(function(s){for(let a=0;a0?(t.polling=!0,processPolling(asElement(n),i,e)):addEventListener(n,i,t,e)}function shouldProcessHxOn(n){let e=asElement(n);if(!e)return!1;let t=e.attributes;for(let i=0;i", "+o).join(""))}else return[]}function maybeSetLastButtonClicked(n){let e=getTargetButton(n.target),t=getRelatedFormData(n);t&&(t.lastButtonClicked=e)}function maybeUnsetLastButtonClicked(n){let e=getRelatedFormData(n);e&&(e.lastButtonClicked=null)}function getTargetButton(n){return closest(asElement(n),"button, input[type='submit']")}function getRelatedForm(n){return n.form||closest(n,"form")}function getRelatedFormData(n){let e=getTargetButton(n.target);if(!e)return;let t=getRelatedForm(e);if(t)return getInternalData(t)}function initButtonTracking(n){n.addEventListener("click",maybeSetLastButtonClicked),n.addEventListener("focusin",maybeSetLastButtonClicked),n.addEventListener("focusout",maybeUnsetLastButtonClicked)}function addHxOnEventHandler(n,e,t){let i=getInternalData(n);Array.isArray(i.onHandlers)||(i.onHandlers=[]);let r,o=function(s){maybeEval(n,function(){eltIsDisabled(n)||(r||(r=new Function("event",t)),r.call(n,s))})};n.addEventListener(e,o),i.onHandlers.push({event:e,listener:o})}function processHxOnWildcard(n){deInitOnHandlers(n);for(let e=0;ehtmx.config.historyCacheSize;)o.shift();for(;o.length>0;)try{sessionStorage.setItem("htmx-history-cache",JSON.stringify(o));break}catch(a){triggerErrorEvent(getDocument().body,"htmx:historyCacheError",{cause:a,cache:o}),o.shift()}}function getCachedHistory(n){if(!canAccessLocalStorage())return null;n=normalizePath(n);let e=parseJSON(sessionStorage.getItem("htmx-history-cache"))||[];for(let t=0;t=200&&this.status<400?(i.response=this.response,triggerEvent(getDocument().body,"htmx:historyCacheMissLoad",i),swap(i.historyElt,i.response,t,{contextElement:i.historyElt,historyRequest:!0}),setCurrentPathForHistory(i.path),triggerEvent(getDocument().body,"htmx:historyRestore",{path:n,cacheMiss:!0,serverResponse:i.response})):triggerErrorEvent(getDocument().body,"htmx:historyCacheMissLoadError",i)},triggerEvent(getDocument().body,"htmx:historyCacheMiss",i)&&e.send()}function restoreHistory(n){saveCurrentPageToHistory(),n=n||location.pathname+location.search;let e=getCachedHistory(n);if(e){let t={swapStyle:"innerHTML",swapDelay:0,settleDelay:0,scroll:e.scroll},i={path:n,item:e,historyElt:getHistoryElement(),swapSpec:t};triggerEvent(getDocument().body,"htmx:historyCacheHit",i)&&(swap(i.historyElt,e.content,t,{contextElement:i.historyElt,title:e.title}),setCurrentPathForHistory(i.path),triggerEvent(getDocument().body,"htmx:historyRestore",i))}else htmx.config.refreshOnHistoryMiss?htmx.location.reload(!0):loadHistoryFromServer(n)}function addRequestIndicatorClasses(n){let e=findAttributeTargets(n,"hx-indicator");return e==null&&(e=[n]),forEach(e,function(t){let i=getInternalData(t);i.requestCount=(i.requestCount||0)+1,t.classList.add.call(t.classList,htmx.config.requestClass)}),e}function disableElements(n){let e=findAttributeTargets(n,"hx-disabled-elt");return e==null&&(e=[]),forEach(e,function(t){let i=getInternalData(t);i.requestCount=(i.requestCount||0)+1,t.setAttribute("disabled",""),t.setAttribute("data-disabled-by-htmx","")}),e}function removeRequestIndicators(n,e){forEach(n.concat(e),function(t){let i=getInternalData(t);i.requestCount=(i.requestCount||1)-1}),forEach(n,function(t){getInternalData(t).requestCount===0&&t.classList.remove.call(t.classList,htmx.config.requestClass)}),forEach(e,function(t){getInternalData(t).requestCount===0&&(t.removeAttribute("disabled"),t.removeAttribute("data-disabled-by-htmx"))})}function haveSeenNode(n,e){for(let t=0;te.indexOf(r)<0):i=i.filter(r=>r!==e),t.delete(n),forEach(i,r=>t.append(n,r))}}function getValueFromInput(n){return n instanceof HTMLSelectElement&&n.multiple?toArray(n.querySelectorAll("option:checked")).map(function(e){return e.value}):n instanceof HTMLInputElement&&n.files?toArray(n.files):n.value}function processInputValue(n,e,t,i,r){if(!(i==null||haveSeenNode(n,i))){if(n.push(i),shouldInclude(i)){let o=getRawAttribute(i,"name");addValueToFormData(o,getValueFromInput(i),e),r&&validateElement(i,t)}i instanceof HTMLFormElement&&(forEach(i.elements,function(o){n.indexOf(o)>=0?removeValueFromFormData(o.name,getValueFromInput(o),e):n.push(o),r&&validateElement(o,t)}),new FormData(i).forEach(function(o,s){o instanceof File&&o.name===""||addValueToFormData(s,o,e)}))}}function validateElement(n,e){let t=n;t.willValidate&&(triggerEvent(t,"htmx:validation:validate"),t.checkValidity()||(triggerEvent(t,"htmx:validation:failed",{message:t.validationMessage,validity:t.validity})&&!e.length&&htmx.config.reportValidityOfForms&&t.reportValidity(),e.push({elt:t,message:t.validationMessage,validity:t.validity})))}function overrideFormData(n,e){for(let t of e.keys())n.delete(t);return e.forEach(function(t,i){n.append(i,t)}),n}function getInputValues(n,e){let t=[],i=new FormData,r=new FormData,o=[],s=getInternalData(n);s.lastButtonClicked&&!bodyContains(s.lastButtonClicked)&&(s.lastButtonClicked=null);let a=n instanceof HTMLFormElement&&n.noValidate!==!0||getAttributeValue(n,"hx-validate")==="true";if(s.lastButtonClicked&&(a=a&&s.lastButtonClicked.formNoValidate!==!0),e!=="get"&&processInputValue(t,r,o,getRelatedForm(n),a),processInputValue(t,i,o,n,a),s.lastButtonClicked||n.tagName==="BUTTON"||n.tagName==="INPUT"&&getRawAttribute(n,"type")==="submit"){let c=s.lastButtonClicked||n,u=getRawAttribute(c,"name");addValueToFormData(u,c.value,r)}let l=findAttributeTargets(n,"hx-include");return forEach(l,function(c){processInputValue(t,i,o,asElement(c),a),matches(c,"form")||forEach(asParentNode(c).querySelectorAll(INPUT_SELECTOR),function(u){processInputValue(t,i,o,u,a)})}),overrideFormData(i,r),{errors:o,formData:i,values:formDataProxy(i)}}function appendParam(n,e,t){n!==""&&(n+="&"),String(t)==="[object Object]"&&(t=JSON.stringify(t));let i=encodeURIComponent(t);return n+=encodeURIComponent(e)+"="+i,n}function urlEncode(n){n=formDataFromObject(n);let e="";return n.forEach(function(t,i){e=appendParam(e,i,t)}),e}function getHeaders(n,e,t){let i={"HX-Request":"true","HX-Trigger":getRawAttribute(n,"id"),"HX-Trigger-Name":getRawAttribute(n,"name"),"HX-Target":getAttributeValue(e,"id"),"HX-Current-URL":location.href};return getValuesForElement(n,"hx-headers",!1,i),t!==void 0&&(i["HX-Prompt"]=t),getInternalData(n).boosted&&(i["HX-Boosted"]="true"),i}function filterValues(n,e){let t=getClosestAttributeValue(e,"hx-params");if(t){if(t==="none")return new FormData;if(t==="*")return n;if(t.indexOf("not ")===0)return forEach(t.slice(4).split(","),function(i){i=i.trim(),n.delete(i)}),n;{let i=new FormData;return forEach(t.split(","),function(r){r=r.trim(),n.has(r)&&n.getAll(r).forEach(function(o){i.append(r,o)})}),i}}else return n}function isAnchorLink(n){return!!getRawAttribute(n,"href")&&getRawAttribute(n,"href").indexOf("#")>=0}function getSwapSpecification(n,e){let t=e||getClosestAttributeValue(n,"hx-swap"),i={swapStyle:getInternalData(n).boosted?"innerHTML":htmx.config.defaultSwapStyle,swapDelay:htmx.config.defaultSwapDelay,settleDelay:htmx.config.defaultSettleDelay};if(htmx.config.scrollIntoViewOnBoost&&getInternalData(n).boosted&&!isAnchorLink(n)&&(i.show="top"),t){let s=splitOnWhitespace(t);if(s.length>0)for(let a=0;a0?r.join(":"):null;i.scroll=u,i.scrollTarget=o}else if(l.indexOf("show:")===0){var r=l.slice(5).split(":");let d=r.pop();var o=r.length>0?r.join(":"):null;i.show=d,i.showTarget=o}else if(l.indexOf("focus-scroll:")===0){let c=l.slice(13);i.focusScroll=c=="true"}else a==0?i.swapStyle=l:logError("Unknown modifier in hx-swap: "+l)}}return i}function usesFormData(n){return getClosestAttributeValue(n,"hx-encoding")==="multipart/form-data"||matches(n,"form")&&getRawAttribute(n,"enctype")==="multipart/form-data"}function encodeParamsForBody(n,e,t){let i=null;return withExtensions(e,function(r){i==null&&(i=r.encodeParameters(n,t,e))}),i!=null?i:usesFormData(e)?overrideFormData(new FormData,formDataFromObject(t)):urlEncode(t)}function makeSettleInfo(n){return{tasks:[],elts:[n]}}function updateScrollState(n,e){let t=n[0],i=n[n.length-1];if(e.scroll){var r=null;e.scrollTarget&&(r=asElement(querySelectorExt(t,e.scrollTarget))),e.scroll==="top"&&(t||r)&&(r=r||t,r.scrollTop=0),e.scroll==="bottom"&&(i||r)&&(r=r||i,r.scrollTop=r.scrollHeight),typeof e.scroll=="number"&&getWindow().setTimeout(function(){window.scrollTo(0,e.scroll)},0)}if(e.show){var r=null;if(e.showTarget){let s=e.showTarget;e.showTarget==="window"&&(s="body"),r=asElement(querySelectorExt(t,s))}e.show==="top"&&(t||r)&&(r=r||t,r.scrollIntoView({block:"start",behavior:htmx.config.scrollBehavior})),e.show==="bottom"&&(i||r)&&(r=r||i,r.scrollIntoView({block:"end",behavior:htmx.config.scrollBehavior}))}}function getValuesForElement(n,e,t,i,r){if(i==null&&(i={}),n==null)return i;let o=getAttributeValue(n,e);if(o){let s=o.trim(),a=t;if(s==="unset")return null;s.indexOf("javascript:")===0?(s=s.slice(11),a=!0):s.indexOf("js:")===0&&(s=s.slice(3),a=!0),s.indexOf("{")!==0&&(s="{"+s+"}");let l;a?l=maybeEval(n,function(){return r?Function("event","return ("+s+")").call(n,r):Function("return ("+s+")").call(n)},{}):l=parseJSON(s);for(let c in l)l.hasOwnProperty(c)&&i[c]==null&&(i[c]=l[c])}return getValuesForElement(asElement(parentElt(n)),e,t,i,r)}function maybeEval(n,e,t){return htmx.config.allowEval?e():(triggerErrorEvent(n,"htmx:evalDisallowedError"),t)}function getHXVarsForElement(n,e,t){return getValuesForElement(n,"hx-vars",!0,t,e)}function getHXValsForElement(n,e,t){return getValuesForElement(n,"hx-vals",!1,t,e)}function getExpressionVars(n,e){return mergeObjects(getHXVarsForElement(n,e),getHXValsForElement(n,e))}function safelySetHeaderValue(n,e,t){if(t!==null)try{n.setRequestHeader(e,t)}catch(i){n.setRequestHeader(e,encodeURIComponent(t)),n.setRequestHeader(e+"-URI-AutoEncoded","true")}}function getPathFromResponse(n){if(n.responseURL)try{let e=new URL(n.responseURL);return e.pathname+e.search}catch(e){triggerErrorEvent(getDocument().body,"htmx:badResponseUrl",{url:n.responseURL})}}function hasHeader(n,e){return e.test(n.getAllResponseHeaders())}function ajaxHelper(n,e,t){if(n=n.toLowerCase(),t){if(t instanceof Element||typeof t=="string")return issueAjaxRequest(n,e,null,null,{targetOverride:resolveTarget(t)||DUMMY_ELT,returnPromise:!0});{let i=resolveTarget(t.target);return(t.target&&!i||t.source&&!i&&!resolveTarget(t.source))&&(i=DUMMY_ELT),issueAjaxRequest(n,e,resolveTarget(t.source),t.event,{handler:t.handler,headers:t.headers,values:t.values,targetOverride:i,swapOverride:t.swap,select:t.select,returnPromise:!0,push:t.push,replace:t.replace,selectOOB:t.selectOOB})}}else return issueAjaxRequest(n,e,null,null,{returnPromise:!0})}function hierarchyForElt(n){let e=[];for(;n;)e.push(n),n=n.parentElement;return e}function verifyPath(n,e,t){let i=new URL(e,location.protocol!=="about:"?location.href:window.origin),o=(location.protocol!=="about:"?location.origin:window.origin)===i.origin;return htmx.config.selfRequestsOnly&&!o?!1:triggerEvent(n,"htmx:validateUrl",mergeObjects({url:i,sameHost:o},t))}function formDataFromObject(n){if(n instanceof FormData)return n;let e=new FormData;for(let t in n)n.hasOwnProperty(t)&&(n[t]&&typeof n[t].forEach=="function"?n[t].forEach(function(i){e.append(t,i)}):typeof n[t]=="object"&&!(n[t]instanceof Blob)?e.append(t,JSON.stringify(n[t])):e.append(t,n[t]));return e}function formDataArrayProxy(n,e,t){return new Proxy(t,{get:function(i,r){return typeof r=="number"?i[r]:r==="length"?i.length:r==="push"?function(o){i.push(o),n.append(e,o)}:typeof i[r]=="function"?function(){i[r].apply(i,arguments),n.delete(e),i.forEach(function(o){n.append(e,o)})}:i[r]&&i[r].length===1?i[r][0]:i[r]},set:function(i,r,o){return i[r]=o,n.delete(e),i.forEach(function(s){n.append(e,s)}),!0}})}function formDataProxy(n){return new Proxy(n,{get:function(e,t){if(typeof t=="symbol"){let r=Reflect.get(e,t);return typeof r=="function"?function(){return r.apply(n,arguments)}:r}if(t==="toJSON")return()=>Object.fromEntries(n);if(t in e&&typeof e[t]=="function")return function(){return n[t].apply(n,arguments)};let i=n.getAll(t);if(i.length!==0)return i.length===1?i[0]:formDataArrayProxy(e,t,i)},set:function(e,t,i){return typeof t!="string"?!1:(e.delete(t),i&&typeof i.forEach=="function"?i.forEach(function(r){e.append(t,r)}):typeof i=="object"&&!(i instanceof Blob)?e.append(t,JSON.stringify(i)):e.append(t,i),!0)},deleteProperty:function(e,t){return typeof t=="string"&&e.delete(t),!0},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function issueAjaxRequest(n,e,t,i,r,o){let s=null,a=null;if(r=r!=null?r:{},r.returnPromise&&typeof Promise!="undefined")var l=new Promise(function(U,Z){s=U,a=Z});t==null&&(t=getDocument().body);let c=r.handler||handleAjaxResponse,u=r.select||null;if(!bodyContains(t))return maybeCall(s),l;let d=r.targetOverride||asElement(getTarget(t));if(d==null||d==DUMMY_ELT)return triggerErrorEvent(t,"htmx:targetError",{target:getClosestAttributeValue(t,"hx-target")}),maybeCall(a),l;let p=getInternalData(t),y=p.lastButtonClicked;if(y){let U=getRawAttribute(y,"formaction");U!=null&&(e=U);let Z=getRawAttribute(y,"formmethod");if(Z!=null)if(VERBS.includes(Z.toLowerCase()))n=Z;else return maybeCall(s),l}let m=getClosestAttributeValue(t,"hx-confirm");if(o===void 0&&triggerEvent(t,"htmx:confirm",{target:d,elt:t,path:e,verb:n,triggeringEvent:i,etc:r,issueRequest:function(oe){return issueAjaxRequest(n,e,t,i,r,!!oe)},question:m})===!1)return maybeCall(s),l;let v=t,w=getClosestAttributeValue(t,"hx-sync"),T=null,_=!1;if(w){let U=w.split(":"),Z=U[0].trim();if(Z==="this"?v=findThisElement(t,"hx-sync"):v=asElement(querySelectorExt(t,Z)),w=(U[1]||"drop").trim(),p=getInternalData(v),w==="drop"&&p.xhr&&p.abortable!==!0)return maybeCall(s),l;if(w==="abort"){if(p.xhr)return maybeCall(s),l;_=!0}else w==="replace"?triggerEvent(v,"htmx:abort"):w.indexOf("queue")===0&&(T=(w.split(" ")[1]||"last").trim())}if(p.xhr)if(p.abortable)triggerEvent(v,"htmx:abort");else{if(T==null){if(i){let U=getInternalData(i);U&&U.triggerSpec&&U.triggerSpec.queue&&(T=U.triggerSpec.queue)}T==null&&(T="last")}return p.queuedRequests==null&&(p.queuedRequests=[]),T==="first"&&p.queuedRequests.length===0?p.queuedRequests.push(function(){issueAjaxRequest(n,e,t,i,r)}):T==="all"?p.queuedRequests.push(function(){issueAjaxRequest(n,e,t,i,r)}):T==="last"&&(p.queuedRequests=[],p.queuedRequests.push(function(){issueAjaxRequest(n,e,t,i,r)})),maybeCall(s),l}let S=new XMLHttpRequest;p.xhr=S,p.abortable=_;let A=function(){p.xhr=null,p.abortable=!1,p.queuedRequests!=null&&p.queuedRequests.length>0&&p.queuedRequests.shift()()},K=getClosestAttributeValue(t,"hx-prompt");if(K){var z=prompt(K);if(z===null||!triggerEvent(t,"htmx:prompt",{prompt:z,target:d}))return maybeCall(s),A(),l}if(m&&!o&&!confirm(m))return maybeCall(s),A(),l;let L=getHeaders(t,d,z);n!=="get"&&!usesFormData(t)&&(L["Content-Type"]="application/x-www-form-urlencoded"),r.headers&&(L=mergeObjects(L,r.headers));let H=getInputValues(t,n),N=H.errors,Y=H.formData;r.values&&overrideFormData(Y,formDataFromObject(r.values));let $=formDataFromObject(getExpressionVars(t,i)),ie=overrideFormData(Y,$),J=filterValues(ie,t);htmx.config.getCacheBusterParam&&n==="get"&&J.set("org.htmx.cache-buster",getRawAttribute(d,"id")||"true"),(e==null||e==="")&&(e=location.href);let Te=getValuesForElement(t,"hx-request"),Ce=getInternalData(t).boosted,se=htmx.config.methodsThatUseUrlParams.indexOf(n)>=0,ne={boosted:Ce,useUrlParams:se,formData:J,parameters:formDataProxy(J),unfilteredFormData:ie,unfilteredParameters:formDataProxy(ie),headers:L,elt:t,target:d,verb:n,errors:N,withCredentials:r.credentials||Te.credentials||htmx.config.withCredentials,timeout:r.timeout||Te.timeout||htmx.config.timeout,path:e,triggeringEvent:i};if(!triggerEvent(t,"htmx:configRequest",ne))return maybeCall(s),A(),l;if(e=ne.path,n=ne.verb,L=ne.headers,J=formDataFromObject(ne.parameters),N=ne.errors,se=ne.useUrlParams,N&&N.length>0)return triggerEvent(t,"htmx:validation:halted",ne),maybeCall(s),A(),l;let qe=e.split("#"),Re=qe[0],W=qe[1],M=e;if(se&&(M=Re,!J.keys().next().done&&(M.indexOf("?")<0?M+="?":M+="&",M+=urlEncode(J),W&&(M+="#"+W))),!verifyPath(t,M,ne))return triggerErrorEvent(t,"htmx:invalidPath",ne),maybeCall(a),A(),l;if(S.open(n.toUpperCase(),M,!0),S.overrideMimeType("text/html"),S.withCredentials=ne.withCredentials,S.timeout=ne.timeout,!Te.noHeaders){for(let U in L)if(L.hasOwnProperty(U)){let Z=L[U];safelySetHeaderValue(S,U,Z)}}let D={xhr:S,target:d,requestConfig:ne,etc:r,boosted:Ce,select:u,pathInfo:{requestPath:e,finalRequestPath:M,responsePath:null,anchor:W}};if(S.onload=function(){try{let U=hierarchyForElt(t);if(D.pathInfo.responsePath=getPathFromResponse(S),c(t,D),D.keepIndicators!==!0&&removeRequestIndicators(B,V),triggerEvent(t,"htmx:afterRequest",D),triggerEvent(t,"htmx:afterOnLoad",D),!bodyContains(t)){let Z=null;for(;U.length>0&&Z==null;){let oe=U.shift();bodyContains(oe)&&(Z=oe)}Z&&(triggerEvent(Z,"htmx:afterRequest",D),triggerEvent(Z,"htmx:afterOnLoad",D))}maybeCall(s)}catch(U){throw triggerErrorEvent(t,"htmx:onLoadError",mergeObjects({error:U},D)),U}finally{A()}},S.onerror=function(){removeRequestIndicators(B,V),triggerErrorEvent(t,"htmx:afterRequest",D),triggerErrorEvent(t,"htmx:sendError",D),maybeCall(a),A()},S.onabort=function(){removeRequestIndicators(B,V),triggerErrorEvent(t,"htmx:afterRequest",D),triggerErrorEvent(t,"htmx:sendAbort",D),maybeCall(a),A()},S.ontimeout=function(){removeRequestIndicators(B,V),triggerErrorEvent(t,"htmx:afterRequest",D),triggerErrorEvent(t,"htmx:timeout",D),maybeCall(a),A()},!triggerEvent(t,"htmx:beforeRequest",D))return maybeCall(s),A(),l;var B=addRequestIndicatorClasses(t),V=disableElements(t);forEach(["loadstart","loadend","progress","abort"],function(U){forEach([S,S.upload],function(Z){Z.addEventListener(U,function(oe){triggerEvent(t,"htmx:xhr:"+U,{lengthComputable:oe.lengthComputable,loaded:oe.loaded,total:oe.total})})})}),triggerEvent(t,"htmx:beforeSend",D);let q=se?null:encodeParamsForBody(S,t,J);return S.send(q),l}function determineHistoryUpdates(n,e){let t=e.xhr,i=null,r=null;if(hasHeader(t,/HX-Push:/i)?(i=t.getResponseHeader("HX-Push"),r="push"):hasHeader(t,/HX-Push-Url:/i)?(i=t.getResponseHeader("HX-Push-Url"),r="push"):hasHeader(t,/HX-Replace-Url:/i)&&(i=t.getResponseHeader("HX-Replace-Url"),r="replace"),i)return i==="false"?{}:{type:r,path:i};let o=e.pathInfo.finalRequestPath,s=e.pathInfo.responsePath,a=e.etc.push||getClosestAttributeValue(n,"hx-push-url"),l=e.etc.replace||getClosestAttributeValue(n,"hx-replace-url"),c=getInternalData(n).boosted,u=null,d=null;return a?(u="push",d=a):l?(u="replace",d=l):c&&(u="push",d=s||o),d?d==="false"?{}:(d==="true"&&(d=s||o),e.pathInfo.anchor&&d.indexOf("#")===-1&&(d=d+"#"+e.pathInfo.anchor),{type:u,path:d}):{}}function codeMatches(n,e){var t=new RegExp(n.code);return t.test(e.toString(10))}function resolveResponseHandling(n){for(var e=0;e.${e}{opacity:0;visibility: hidden} .${t} .${e}, .${t}.${e}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}`)}}function getMetaConfig(){let n=getDocument().querySelector('meta[name="htmx-config"]');return n?parseJSON(n.content):null}function mergeMetaConfig(){let n=getMetaConfig();n&&(htmx.config=mergeObjects(htmx.config,n))}return ready(function(){mergeMetaConfig(),insertIndicatorStyles();let n=getDocument().body;processNode(n);let e=getDocument().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");n.addEventListener("htmx:abort",function(i){let r=i.detail.elt||i.target,o=getInternalData(r);o&&o.xhr&&o.xhr.abort()});let t=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(i){i.state&&i.state.htmx?(restoreHistory(),forEach(e,function(r){triggerEvent(r,"htmx:restored",{document:getDocument(),triggerEvent})})):t&&t(i)},getWindow().setTimeout(function(){triggerEvent(n,"htmx:load",{}),n=null},0)}),htmx})(),x_=Kp;function ko(n,e){n.split(/\s+/).forEach(t=>{e(t)})}var oi=class{constructor(){this._events={}}on(e,t){ko(e,i=>{let r=this._events[i]||[];r.push(t),this._events[i]=r})}off(e,t){var i=arguments.length;if(i===0){this._events={};return}ko(e,r=>{if(i===1){delete this._events[r];return}let o=this._events[r];o!==void 0&&(o.splice(o.indexOf(t),1),this._events[r]=o)})}trigger(e,...t){var i=this;ko(e,r=>{let o=i._events[r];o!==void 0&&o.forEach(s=>{s.apply(i,t)})})}};function No(n){return n.plugins={},class extends n{constructor(){super(...arguments),this.plugins={names:[],settings:{},requested:{},loaded:{}}}static define(e,t){n.plugins[e]={name:e,fn:t}}initializePlugins(e){var t,i;let r=this,o=[];if(Array.isArray(e))e.forEach(s=>{typeof s=="string"?o.push(s):(r.plugins.settings[s.name]=s.options,o.push(s.name))});else if(e)for(t in e)e.hasOwnProperty(t)&&(r.plugins.settings[t]=e[t],o.push(t));for(;i=o.shift();)r.require(i)}loadPlugin(e){var t=this,i=t.plugins,r=n.plugins[e];if(!n.plugins.hasOwnProperty(e))throw new Error('Unable to find "'+e+'" plugin');i.requested[e]=!0,i.loaded[e]=r.fn.apply(t,[t.plugins.settings[e]||{}]),i.names.push(e)}require(e){var t=this,i=t.plugins;if(!t.plugins.loaded.hasOwnProperty(e)){if(i.requested[e])throw new Error('Plugin has circular dependency ("'+e+'")');t.loadPlugin(e)}return i.loaded[e]}}}var si=n=>(n=n.filter(Boolean),n.length<2?n[0]||"":Qp(n)==1?"["+n.join("")+"]":"(?:"+n.join("|")+")"),Io=n=>{if(!Xp(n))return n.join("");let e="",t=0,i=()=>{t>1&&(e+="{"+t+"}")};return n.forEach((r,o)=>{if(r===n[o-1]){t++;return}i(),e+=r,t=1}),i(),e},Ro=n=>{let e=Array.from(n);return si(e)},Xp=n=>new Set(n).size!==n.length,Lt=n=>(n+"").replace(/([\$\(\)\*\+\.\?\[\]\^\{\|\}\\])/gu,"\\$1"),Qp=n=>n.reduce((e,t)=>Math.max(e,Jp(t)),0),Jp=n=>Array.from(n).length;var Ho=n=>{if(n.length===1)return[[n]];let e=[],t=n.substring(1);return Ho(t).forEach(function(r){let o=r.slice(0);o[0]=n.charAt(0)+o[0],e.push(o),o=r.slice(0),o.unshift(n.charAt(0)),e.push(o)}),e};var Zp=[[0,65535]],em="[\u0300-\u036F\xB7\u02BE\u02BC]",or,Ba,tm=3,Po={},Pa={"/":"\u2044\u2215",0:"\u07C0",a:"\u2C65\u0250\u0251",aa:"\uA733",ae:"\xE6\u01FD\u01E3",ao:"\uA735",au:"\uA737",av:"\uA739\uA73B",ay:"\uA73D",b:"\u0180\u0253\u0183",c:"\uA73F\u0188\u023C\u2184",d:"\u0111\u0257\u0256\u1D05\u018C\uABB7\u0501\u0266",e:"\u025B\u01DD\u1D07\u0247",f:"\uA77C\u0192",g:"\u01E5\u0260\uA7A1\u1D79\uA77F\u0262",h:"\u0127\u2C68\u2C76\u0265",i:"\u0268\u0131",j:"\u0249\u0237",k:"\u0199\u2C6A\uA741\uA743\uA745\uA7A3",l:"\u0142\u019A\u026B\u2C61\uA749\uA747\uA781\u026D",m:"\u0271\u026F\u03FB",n:"\uA7A5\u019E\u0272\uA791\u1D0E\u043B\u0509",o:"\xF8\u01FF\u0254\u0275\uA74B\uA74D\u1D11",oe:"\u0153",oi:"\u01A3",oo:"\uA74F",ou:"\u0223",p:"\u01A5\u1D7D\uA751\uA753\uA755\u03C1",q:"\uA757\uA759\u024B",r:"\u024D\u027D\uA75B\uA7A7\uA783",s:"\xDF\u023F\uA7A9\uA785\u0282",t:"\u0167\u01AD\u0288\u2C66\uA787",th:"\xFE",tz:"\uA729",u:"\u0289",v:"\u028B\uA75F\u028C",vy:"\uA761",w:"\u2C73",y:"\u01B4\u024F\u1EFF",z:"\u01B6\u0225\u0240\u2C6C\uA763",hv:"\u0195"};for(let n in Pa){let e=Pa[n]||"";for(let t=0;t{or===void 0&&(or=am(n||Zp))},Fa=(n,e="NFKD")=>n.normalize(e),ai=n=>Array.from(n).reduce((e,t)=>e+rm(t),""),rm=n=>(n=Fa(n).toLowerCase().replace(nm,e=>Po[e]||""),Fa(n,"NFC"));function*om(n){for(let[e,t]of n)for(let i=e;i<=t;i++){let r=String.fromCharCode(i),o=ai(r);o!=r.toLowerCase()&&(o.length>tm||o.length!=0&&(yield{folded:o,composed:r,code_point:i}))}}var sm=n=>{let e={},t=(i,r)=>{let o=e[i]||new Set,s=new RegExp("^"+Ro(o)+"$","iu");r.match(s)||(o.add(Lt(r)),e[i]=o)};for(let i of om(n))t(i.folded,i.folded),t(i.folded,i.composed);return e},am=n=>{let e=sm(n),t={},i=[];for(let o in e){let s=e[o];s&&(t[o]=Ro(s)),o.length>1&&i.push(Lt(o))}i.sort((o,s)=>s.length-o.length);let r=si(i);return Ba=new RegExp("^"+r,"u"),t},lm=(n,e=1)=>{let t=0;return n=n.map(i=>(or[i]&&(t+=i.length),or[i]||i)),t>=e?Io(n):""},cm=(n,e=1)=>(e=Math.max(e,n.length-1),si(Ho(n).map(t=>lm(t,e)))),$a=(n,e=!0)=>{let t=n.length>1?1:0;return si(n.map(i=>{let r=[],o=e?i.length():i.length()-1;for(let s=0;s{for(let t of e){if(t.start!=n.start||t.end!=n.end||t.substrs.join("")!==n.substrs.join(""))continue;let i=n.parts,r=s=>{for(let a of i){if(a.start===s.start&&a.substr===s.substr)return!1;if(!(s.length==1||a.length==1)&&(s.starta.start||a.starts.start))return!0}return!1};if(!(t.parts.filter(r).length>0))return!0}return!1},sr=class n{constructor(){ee(this,"parts");ee(this,"substrs");ee(this,"start");ee(this,"end");this.parts=[],this.substrs=[],this.start=0,this.end=0}add(e){e&&(this.parts.push(e),this.substrs.push(e.substr),this.start=Math.min(e.start,this.start),this.end=Math.max(e.end,this.end))}last(){return this.parts[this.parts.length-1]}length(){return this.parts.length}clone(e,t){let i=new n,r=JSON.parse(JSON.stringify(this.parts)),o=r.pop();for(let l of r)i.add(l);let s=t.substr.substring(0,e-o.start),a=s.length;return i.add({start:o.start,end:o.start+a,length:a,substr:s}),i}},Va=n=>{im(),n=ai(n);let e="",t=[new sr];for(let i=0;i0){l=l.sort((u,d)=>u.length()-d.length());for(let u of l)um(u,t)||t.push(u);continue}if(i>0&&c.size==1&&!c.has("3")){e+=$a(t,!1);let u=new sr,d=t[0];d&&u.add(d.last()),t=[u]}}return e+=$a(t,!0),e};var za=(n,e)=>{if(n)return n[e]},ja=(n,e)=>{if(n){for(var t,i=e.split(".");(t=i.shift())&&(n=n[t]););return n}},ar=(n,e,t)=>{var i,r;return!n||(n=n+"",e.regex==null)||(r=n.search(e.regex),r===-1)?0:(i=e.string.length/n.length,r===0&&(i+=.5),i*t)},lr=(n,e)=>{var t=n[e];if(typeof t=="function")return t;t&&!Array.isArray(t)&&(n[e]=[t])},li=(n,e)=>{if(Array.isArray(n))n.forEach(e);else for(var t in n)n.hasOwnProperty(t)&&e(n[t],t)},Wa=(n,e)=>typeof n=="number"&&typeof e=="number"?n>e?1:ne?1:e>n?-1:0);var cr=class{constructor(e,t){ee(this,"items");ee(this,"settings");this.items=e,this.settings=t||{diacritics:!0}}tokenize(e,t,i){if(!e||!e.length)return[];let r=[],o=e.split(/\s+/);var s;return i&&(s=new RegExp("^("+Object.keys(i).map(Lt).join("|")+"):(.*)$")),o.forEach(a=>{let l,c=null,u=null;s&&(l=a.match(s))&&(c=l[1],a=l[2]),a.length>0&&(this.settings.diacritics?u=Va(a)||null:u=Lt(a),u&&t&&(u="\\b"+u)),r.push({string:a,regex:u?new RegExp(u,"iu"):null,field:c})}),r}getScoreFunction(e,t){var i=this.prepareSearch(e,t);return this._getScoreFunction(i)}_getScoreFunction(e){let t=e.tokens,i=t.length;if(!i)return function(){return 0};let r=e.options.fields,o=e.weights,s=r.length,a=e.getAttrFn;if(!s)return function(){return 1};let l=(function(){return s===1?function(c,u){let d=r[0].field;return ar(a(u,d),c,o[d]||1)}:function(c,u){var d=0;if(c.field){let p=a(u,c.field);!c.regex&&p?d+=1/s:d+=ar(p,c,1)}else li(o,(p,y)=>{d+=ar(a(u,y),c,p)});return d/s}})();return i===1?function(c){return l(t[0],c)}:e.options.conjunction==="and"?function(c){var u,d=0;for(let p of t){if(u=l(p,c),u<=0)return 0;d+=u}return d/i}:function(c){var u=0;return li(t,d=>{u+=l(d,c)}),u/i}}getSortFunction(e,t){var i=this.prepareSearch(e,t);return this._getSortFunction(i)}_getSortFunction(e){var t,i=[];let r=this,o=e.options,s=!e.query&&o.sort_empty?o.sort_empty:o.sort;if(typeof s=="function")return s.bind(this);let a=function(c,u){return c==="$score"?u.score:e.getAttrFn(r.items[u.id],c)};if(s)for(let c of s)(e.query||c.field!=="$score")&&i.push(c);if(e.query){t=!0;for(let c of i)if(c.field==="$score"){t=!1;break}t&&i.unshift({field:"$score",direction:"desc"})}else i=i.filter(c=>c.field!=="$score");return i.length?function(c,u){var d,p;for(let y of i)if(p=y.field,d=(y.direction==="desc"?-1:1)*Wa(a(p,c),a(p,u)),d)return d;return 0}:null}prepareSearch(e,t){let i={};var r=Object.assign({},t);if(lr(r,"sort"),lr(r,"sort_empty"),r.fields){lr(r,"fields");let o=[];r.fields.forEach(s=>{typeof s=="string"&&(s={field:s,weight:1}),o.push(s),i[s.field]="weight"in s?s.weight:1}),r.fields=o}return{options:r,query:e.toLowerCase().trim(),tokens:this.tokenize(e,r.respect_word_boundaries,i),total:0,items:[],weights:i,getAttrFn:r.nesting?ja:za}}search(e,t){var i=this,r,o;o=this.prepareSearch(e,t),t=o.options,e=o.query;let s=t.score||i._getScoreFunction(o);e.length?li(i.items,(l,c)=>{r=s(l),(t.filter===!1||r>0)&&o.items.push({score:r,id:c})}):li(i.items,(l,c)=>{o.items.push({score:1,id:c})});let a=i._getSortFunction(o);return a&&o.items.sort(a),o.total=o.items.length,typeof t.limit=="number"&&(o.items=o.items.slice(0,t.limit)),o}};var We=n=>typeof n=="undefined"||n===null?null:ci(n),ci=n=>typeof n=="boolean"?n?"1":"0":n+"",ur=n=>(n+"").replace(/&/g,"&").replace(//g,">").replace(/"/g,"""),qa=(n,e)=>e>0?window.setTimeout(n,e):(n.call(null),null),Ua=(n,e)=>{var t;return function(i,r){var o=this;t&&(o.loading=Math.max(o.loading-1,0),clearTimeout(t)),t=setTimeout(function(){t=null,o.loadedSearches[i]=!0,n.call(o,i,r)},e)}},Fo=(n,e,t)=>{var i,r=n.trigger,o={};n.trigger=function(){var s=arguments[0];if(e.indexOf(s)!==-1)o[s]=arguments;else return r.apply(n,arguments)},t.apply(n,[]),n.trigger=r;for(i of e)i in o&&r.apply(n,o[i])},Ya=n=>({start:n.selectionStart||0,length:(n.selectionEnd||0)-(n.selectionStart||0)}),fe=(n,e=!1)=>{n&&(n.preventDefault(),e&&n.stopPropagation())},Se=(n,e,t,i)=>{n.addEventListener(e,t,i)},Mt=(n,e)=>{if(!e||!e[n])return!1;var t=(e.altKey?1:0)+(e.ctrlKey?1:0)+(e.shiftKey?1:0)+(e.metaKey?1:0);return t===1},dr=(n,e)=>{let t=n.getAttribute("id");return t||(n.setAttribute("id",e),e)},$o=n=>n.replace(/[\\"']/g,"\\$&"),kt=(n,e)=>{e&&n.append(e)},ge=(n,e)=>{if(Array.isArray(n))n.forEach(e);else for(var t in n)n.hasOwnProperty(t)&&e(n[t],t)};var nt=n=>{if(n.jquery)return n[0];if(n instanceof HTMLElement)return n;if(Bo(n)){var e=document.createElement("template");return e.innerHTML=n.trim(),e.content.firstChild}return document.querySelector(n)},Bo=n=>typeof n=="string"&&n.indexOf("<")>-1,Ga=n=>n.replace(/['"\\]/g,"\\$&"),fr=(n,e)=>{var t=document.createEvent("HTMLEvents");t.initEvent(e,!0,!1),n.dispatchEvent(t)},ui=(n,e)=>{Object.assign(n.style,e)},Pe=(n,...e)=>{var t=Ka(e);n=Xa(n),n.map(i=>{t.map(r=>{i.classList.add(r)})})},gt=(n,...e)=>{var t=Ka(e);n=Xa(n),n.map(i=>{t.map(r=>{i.classList.remove(r)})})},Ka=n=>{var e=[];return ge(n,t=>{typeof t=="string"&&(t=t.trim().split(/[\t\n\f\r\s]/)),Array.isArray(t)&&(e=e.concat(t))}),e.filter(Boolean)},Xa=n=>(Array.isArray(n)||(n=[n]),n),hr=(n,e,t)=>{if(!(t&&!t.contains(n)))for(;n&&n.matches;){if(n.matches(e))return n;n=n.parentNode}},Vo=(n,e=0)=>e>0?n[n.length-1]:n[0],Qa=n=>Object.keys(n).length===0,zo=(n,e)=>{if(!n)return-1;e=e||n.nodeName;for(var t=0;n=n.previousElementSibling;)n.matches(e)&&t++;return t},le=(n,e)=>{ge(e,(t,i)=>{t==null?n.removeAttribute(i):n.setAttribute(i,""+t)})},di=(n,e)=>{n.parentNode&&n.parentNode.replaceChild(e,n)};var Ja=(n,e)=>{if(e===null)return;if(typeof e=="string"){if(!e.length)return;e=new RegExp(e,"i")}let t=o=>{var s=o.data.match(e);if(s&&o.data.length>0){var a=document.createElement("span");a.className="highlight";var l=o.splitText(s.index);l.splitText(s[0].length);var c=l.cloneNode(!0);return a.appendChild(c),di(l,a),1}return 0},i=o=>{o.nodeType===1&&o.childNodes&&!/(script|style)/i.test(o.tagName)&&(o.className!=="highlight"||o.tagName!=="SPAN")&&Array.from(o.childNodes).forEach(s=>{r(s)})},r=o=>o.nodeType===3?t(o):(i(o),0);r(n)},Za=n=>{var e=n.querySelectorAll("span.highlight");Array.prototype.forEach.call(e,function(t){var i=t.parentNode;i.replaceChild(t.firstChild,t),i.normalize()})};var dm=typeof navigator=="undefined"?!1:/Mac/.test(navigator.userAgent),fi=dm?"metaKey":"ctrlKey";var jo={options:[],optgroups:[],plugins:[],delimiter:",",splitOn:null,persist:!0,diacritics:!0,create:null,createOnBlur:!1,createFilter:null,highlight:!0,openOnFocus:!0,shouldOpen:null,maxOptions:50,maxItems:null,hideSelected:null,duplicates:!1,addPrecedence:!1,selectOnTab:!1,preload:null,allowEmptyOption:!1,refreshThrottle:300,loadThrottle:300,loadingClass:"loading",dataAttr:null,optgroupField:"optgroup",valueField:"value",labelField:"text",disabledField:"disabled",optgroupLabelField:"label",optgroupValueField:"value",lockOptgroupOrder:!1,sortField:"$order",searchField:["text"],searchConjunction:"and",mode:null,wrapperClass:"ts-wrapper",controlClass:"ts-control",dropdownClass:"ts-dropdown",dropdownContentClass:"ts-dropdown-content",itemClass:"item",optionClass:"option",dropdownParent:null,controlInput:'',copyClassesToDropdown:!1,placeholder:null,hidePlaceholder:null,shouldLoad:function(n){return n.length>0},render:{}};function pr(n,e){var t=Object.assign({},jo,e),i=t.dataAttr,r=t.labelField,o=t.valueField,s=t.disabledField,a=t.optgroupField,l=t.optgroupLabelField,c=t.optgroupValueField,u=n.tagName.toLowerCase(),d=n.getAttribute("placeholder")||n.getAttribute("data-placeholder");if(!d&&!t.allowEmptyOption){let v=n.querySelector('option[value=""]');v&&(d=v.textContent)}var p={placeholder:d,options:[],optgroups:[],items:[],maxItems:null},y=()=>{var v,w=p.options,T={},_=1;let S=0;var A=L=>{var H=Object.assign({},L.dataset),N=i&&H[i];return typeof N=="string"&&N.length&&(H=Object.assign(H,JSON.parse(N))),H},K=(L,H)=>{var N=We(L.value);if(N!=null&&!(!N&&!t.allowEmptyOption)){if(T.hasOwnProperty(N)){if(H){var Y=T[N][a];Y?Array.isArray(Y)?Y.push(H):T[N][a]=[Y,H]:T[N][a]=H}}else{var $=A(L);$[r]=$[r]||L.textContent,$[o]=$[o]||N,$[s]=$[s]||L.disabled,$[a]=$[a]||H,$.$option=L,$.$order=$.$order||++S,T[N]=$,w.push($)}L.selected&&p.items.push(N)}},z=L=>{var H,N;N=A(L),N[l]=N[l]||L.getAttribute("label")||"",N[c]=N[c]||_++,N[s]=N[s]||L.disabled,N.$order=N.$order||++S,p.optgroups.push(N),H=N[c],ge(L.children,Y=>{K(Y,H)})};p.maxItems=n.hasAttribute("multiple")?null:1,ge(n.children,L=>{v=L.tagName.toLowerCase(),v==="optgroup"?z(L):v==="option"&&K(L)})},m=()=>{let v=n.getAttribute(i);if(v)p.options=JSON.parse(v),ge(p.options,T=>{p.items.push(T[o])});else{var w=n.value.trim()||"";if(!t.allowEmptyOption&&!w.length)return;let T=w.split(t.delimiter);ge(T,_=>{let S={};S[r]=_,S[o]=_,p.options.push(S)}),p.items=T}};return u==="select"?y():m(),Object.assign({},jo,p,e)}var nl=0,_e=class extends No(oi){constructor(e,t){super(),this.order=0,this.isOpen=!1,this.isDisabled=!1,this.isReadOnly=!1,this.isInvalid=!1,this.isValid=!0,this.isLocked=!1,this.isFocused=!1,this.isInputHidden=!1,this.isSetup=!1,this.ignoreFocus=!1,this.ignoreHover=!1,this.hasOptions=!1,this.lastValue="",this.caretPos=0,this.loading=0,this.loadedSearches={},this.activeOption=null,this.activeItems=[],this.optgroups={},this.options={},this.userOptions={},this.items=[],this.refreshTimeout=null,nl++;var i,r=nt(e);if(r.tomselect)throw new Error("Tom Select already initialized on this element");r.tomselect=this;var o=window.getComputedStyle&&window.getComputedStyle(r,null);i=o.getPropertyValue("direction");let s=pr(r,t);this.settings=s,this.input=r,this.tabIndex=r.tabIndex||0,this.is_select_tag=r.tagName.toLowerCase()==="select",this.rtl=/rtl/i.test(i),this.inputId=dr(r,"tomselect-"+nl),this.isRequired=r.required,this.sifter=new cr(this.options,{diacritics:s.diacritics}),s.mode=s.mode||(s.maxItems===1?"single":"multi"),typeof s.hideSelected!="boolean"&&(s.hideSelected=s.mode==="multi"),typeof s.hidePlaceholder!="boolean"&&(s.hidePlaceholder=s.mode!=="multi");var a=s.createFilter;typeof a!="function"&&(typeof a=="string"&&(a=new RegExp(a)),a instanceof RegExp?s.createFilter=w=>a.test(w):s.createFilter=w=>this.settings.duplicates||!this.options[w]),this.initializePlugins(s.plugins),this.setupCallbacks(),this.setupTemplates();let l=nt("
"),c=nt("
"),u=this._render("dropdown"),d=nt('
'),p=this.input.getAttribute("class")||"",y=s.mode;var m;if(Pe(l,s.wrapperClass,p,y),Pe(c,s.controlClass),kt(l,c),Pe(u,s.dropdownClass,y),s.copyClassesToDropdown&&Pe(u,p),Pe(d,s.dropdownContentClass),kt(u,d),nt(s.dropdownParent||l).appendChild(u),Bo(s.controlInput)){m=nt(s.controlInput);var v=["autocorrect","autocapitalize","autocomplete","spellcheck"];ge(v,w=>{r.getAttribute(w)&&le(m,{[w]:r.getAttribute(w)})}),m.tabIndex=-1,c.appendChild(m),this.focus_node=m}else s.controlInput?(m=nt(s.controlInput),this.focus_node=m):(m=nt(""),this.focus_node=c);this.wrapper=l,this.dropdown=u,this.dropdown_content=d,this.control=c,this.control_input=m,this.setup()}setup(){let e=this,t=e.settings,i=e.control_input,r=e.dropdown,o=e.dropdown_content,s=e.wrapper,a=e.control,l=e.input,c=e.focus_node,u={passive:!0},d=e.inputId+"-ts-dropdown";le(o,{id:d}),le(c,{role:"combobox","aria-haspopup":"listbox","aria-expanded":"false","aria-controls":d});let p=dr(c,e.inputId+"-ts-control"),y="label[for='"+Ga(e.inputId)+"']",m=document.querySelector(y),v=e.focus.bind(e);if(m){Se(m,"click",v),le(m,{for:p});let _=dr(m,e.inputId+"-ts-label");le(c,{"aria-labelledby":_}),le(o,{"aria-labelledby":_})}if(s.style.width=l.style.width,e.plugins.names.length){let _="plugin-"+e.plugins.names.join(" plugin-");Pe([s,r],_)}(t.maxItems===null||t.maxItems>1)&&e.is_select_tag&&le(l,{multiple:"multiple"}),t.placeholder&&le(i,{placeholder:t.placeholder}),!t.splitOn&&t.delimiter&&(t.splitOn=new RegExp("\\s*"+Lt(t.delimiter)+"+\\s*")),t.load&&t.loadThrottle&&(t.load=Ua(t.load,t.loadThrottle)),Se(r,"mousemove",()=>{e.ignoreHover=!1}),Se(r,"mouseenter",_=>{var S=hr(_.target,"[data-selectable]",r);S&&e.onOptionHover(_,S)},{capture:!0}),Se(r,"click",_=>{let S=hr(_.target,"[data-selectable]");S&&(e.onOptionSelect(_,S),fe(_,!0))}),Se(a,"click",_=>{var S=hr(_.target,"[data-ts-item]",a);if(S&&e.onItemSelect(_,S)){fe(_,!0);return}i.value==""&&(e.onClick(),fe(_,!0))}),Se(c,"keydown",_=>e.onKeyDown(_)),Se(i,"keypress",_=>e.onKeyPress(_)),Se(i,"input",_=>e.onInput(_)),Se(c,"blur",_=>e.onBlur(_)),Se(c,"focus",_=>e.onFocus(_)),Se(i,"paste",_=>e.onPaste(_));let w=_=>{let S=_.composedPath()[0];if(!s.contains(S)&&!r.contains(S)){e.isFocused&&e.blur(),e.inputState();return}S==i&&e.isOpen?_.stopPropagation():fe(_,!0)},T=()=>{e.isOpen&&e.positionDropdown()};Se(document,"mousedown",w),Se(window,"scroll",T,u),Se(window,"resize",T,u),this._destroy=()=>{document.removeEventListener("mousedown",w),window.removeEventListener("scroll",T),window.removeEventListener("resize",T),m&&m.removeEventListener("click",v)},this.revertSettings={innerHTML:l.innerHTML,tabIndex:l.tabIndex},l.tabIndex=-1,l.insertAdjacentElement("afterend",e.wrapper),e.sync(!1),t.items=[],delete t.optgroups,delete t.options,Se(l,"invalid",()=>{e.isValid&&(e.isValid=!1,e.isInvalid=!0,e.refreshState())}),e.updateOriginalInput(),e.refreshItems(),e.close(!1),e.inputState(),e.isSetup=!0,l.disabled?e.disable():l.readOnly?e.setReadOnly(!0):e.enable(),e.on("change",this.onChange),Pe(l,"tomselected","ts-hidden-accessible"),e.trigger("initialize"),t.preload===!0&&e.preload()}setupOptions(e=[],t=[]){this.addOptions(e),ge(t,i=>{this.registerOptionGroup(i)})}setupTemplates(){var e=this,t=e.settings.labelField,i=e.settings.optgroupLabelField,r={optgroup:o=>{let s=document.createElement("div");return s.className="optgroup",s.appendChild(o.options),s},optgroup_header:(o,s)=>'
'+s(o[i])+"
",option:(o,s)=>"
"+s(o[t])+"
",item:(o,s)=>"
"+s(o[t])+"
",option_create:(o,s)=>'
Add '+s(o.input)+"
",no_results:()=>'
No results found
',loading:()=>'
',not_loading:()=>{},dropdown:()=>"
"};e.settings.render=Object.assign({},r,e.settings.render)}setupCallbacks(){var e,t,i={initialize:"onInitialize",change:"onChange",item_add:"onItemAdd",item_remove:"onItemRemove",item_select:"onItemSelect",clear:"onClear",option_add:"onOptionAdd",option_remove:"onOptionRemove",option_clear:"onOptionClear",optgroup_add:"onOptionGroupAdd",optgroup_remove:"onOptionGroupRemove",optgroup_clear:"onOptionGroupClear",dropdown_open:"onDropdownOpen",dropdown_close:"onDropdownClose",type:"onType",load:"onLoad",focus:"onFocus",blur:"onBlur"};for(e in i)t=this.settings[i[e]],t&&this.on(e,t)}sync(e=!0){let t=this,i=e?pr(t.input,{delimiter:t.settings.delimiter}):t.settings;t.setupOptions(i.options,i.optgroups),t.setValue(i.items||[],!0),t.lastQuery=null}onClick(){var e=this;if(e.activeItems.length>0){e.clearActiveItems(),e.focus();return}e.isFocused&&e.isOpen?e.blur():e.focus()}onMouseDown(){}onChange(){fr(this.input,"input"),fr(this.input,"change")}onPaste(e){var t=this;if(t.isInputHidden||t.isLocked){fe(e);return}t.settings.splitOn&&setTimeout(()=>{var i=t.inputValue();if(i.match(t.settings.splitOn)){var r=i.trim().split(t.settings.splitOn);ge(r,o=>{We(o)&&(this.options[o]?t.addItem(o):t.createItem(o))})}},0)}onKeyPress(e){var t=this;if(t.isLocked){fe(e);return}var i=String.fromCharCode(e.keyCode||e.which);if(t.settings.create&&t.settings.mode==="multi"&&i===t.settings.delimiter){t.createItem(),fe(e);return}}onKeyDown(e){var t=this;if(t.ignoreHover=!0,t.isLocked){e.keyCode!==9&&fe(e);return}switch(e.keyCode){case 65:if(Mt(fi,e)&&t.control_input.value==""){fe(e),t.selectAll();return}break;case 27:t.isOpen&&(fe(e,!0),t.close()),t.clearActiveItems();return;case 40:if(!t.isOpen&&t.hasOptions)t.open();else if(t.activeOption){let i=t.getAdjacent(t.activeOption,1);i&&t.setActiveOption(i)}fe(e);return;case 38:if(t.activeOption){let i=t.getAdjacent(t.activeOption,-1);i&&t.setActiveOption(i)}fe(e);return;case 13:t.canSelect(t.activeOption)?(t.onOptionSelect(e,t.activeOption),fe(e)):t.settings.create&&t.createItem()?fe(e):document.activeElement==t.control_input&&t.isOpen&&fe(e);return;case 37:t.advanceSelection(-1,e);return;case 39:t.advanceSelection(1,e);return;case 9:t.settings.selectOnTab&&(t.canSelect(t.activeOption)&&(t.onOptionSelect(e,t.activeOption),fe(e)),t.settings.create&&t.createItem()&&fe(e));return;case 8:case 46:t.deleteSelection(e);return}t.isInputHidden&&!Mt(fi,e)&&fe(e)}onInput(e){if(this.isLocked)return;let t=this.inputValue();if(this.lastValue!==t){if(this.lastValue=t,t==""){this._onInput();return}this.refreshTimeout&&window.clearTimeout(this.refreshTimeout),this.refreshTimeout=qa(()=>{this.refreshTimeout=null,this._onInput()},this.settings.refreshThrottle)}}_onInput(){let e=this.lastValue;this.settings.shouldLoad.call(this,e)&&this.load(e),this.refreshOptions(),this.trigger("type",e)}onOptionHover(e,t){this.ignoreHover||this.setActiveOption(t,!1)}onFocus(e){var t=this,i=t.isFocused;if(t.isDisabled||t.isReadOnly){t.blur(),fe(e);return}t.ignoreFocus||(t.isFocused=!0,t.settings.preload==="focus"&&t.preload(),i||t.trigger("focus"),t.activeItems.length||(t.inputState(),t.refreshOptions(!!t.settings.openOnFocus)),t.refreshState())}onBlur(e){if(document.hasFocus()!==!1){var t=this;if(t.isFocused){t.isFocused=!1,t.ignoreFocus=!1;var i=()=>{t.close(),t.setActiveItem(),t.setCaret(t.items.length),t.trigger("blur")};t.settings.create&&t.settings.createOnBlur?t.createItem(null,i):i()}}}onOptionSelect(e,t){var i,r=this;t.parentElement&&t.parentElement.matches("[data-disabled]")||(t.classList.contains("create")?r.createItem(null,()=>{r.settings.closeAfterSelect&&r.close()}):(i=t.dataset.value,typeof i!="undefined"&&(r.lastQuery=null,r.addItem(i),r.settings.closeAfterSelect&&r.close(),!r.settings.hideSelected&&e.type&&/click/.test(e.type)&&r.setActiveOption(t))))}canSelect(e){return!!(this.isOpen&&e&&this.dropdown_content.contains(e))}onItemSelect(e,t){var i=this;return!i.isLocked&&i.settings.mode==="multi"?(fe(e),i.setActiveItem(t,e),!0):!1}canLoad(e){return!(!this.settings.load||this.loadedSearches.hasOwnProperty(e))}load(e){let t=this;if(!t.canLoad(e))return;Pe(t.wrapper,t.settings.loadingClass),t.loading++;let i=t.loadCallback.bind(t);t.settings.load.call(t,e,i)}loadCallback(e,t){let i=this;i.loading=Math.max(i.loading-1,0),i.lastQuery=null,i.clearActiveOption(),i.setupOptions(e,t),i.refreshOptions(i.isFocused&&!i.isInputHidden),i.loading||gt(i.wrapper,i.settings.loadingClass),i.trigger("load",e,t)}preload(){var e=this.wrapper.classList;e.contains("preloaded")||(e.add("preloaded"),this.load(""))}setTextboxValue(e=""){var t=this.control_input,i=t.value!==e;i&&(t.value=e,fr(t,"update"),this.lastValue=e)}getValue(){return this.is_select_tag&&this.input.hasAttribute("multiple")?this.items:this.items.join(this.settings.delimiter)}setValue(e,t){var i=t?[]:["change"];Fo(this,i,()=>{this.clear(t),this.addItems(e,t)})}setMaxItems(e){e===0&&(e=null),this.settings.maxItems=e,this.refreshState()}setActiveItem(e,t){var i=this,r,o,s,a,l,c;if(i.settings.mode!=="single"){if(!e){i.clearActiveItems(),i.isFocused&&i.inputState();return}if(r=t&&t.type.toLowerCase(),r==="click"&&Mt("shiftKey",t)&&i.activeItems.length){for(c=i.getLastActive(),s=Array.prototype.indexOf.call(i.control.children,c),a=Array.prototype.indexOf.call(i.control.children,e),s>a&&(l=s,s=a,a=l),o=s;o<=a;o++)e=i.control.children[o],i.activeItems.indexOf(e)===-1&&i.setActiveItemClass(e);fe(t)}else r==="click"&&Mt(fi,t)||r==="keydown"&&Mt("shiftKey",t)?e.classList.contains("active")?i.removeActiveItem(e):i.setActiveItemClass(e):(i.clearActiveItems(),i.setActiveItemClass(e));i.inputState(),i.isFocused||i.focus()}}setActiveItemClass(e){let t=this,i=t.control.querySelector(".last-active");i&>(i,"last-active"),Pe(e,"active last-active"),t.trigger("item_select",e),t.activeItems.indexOf(e)==-1&&t.activeItems.push(e)}removeActiveItem(e){var t=this.activeItems.indexOf(e);this.activeItems.splice(t,1),gt(e,"active")}clearActiveItems(){gt(this.activeItems,"active"),this.activeItems=[]}setActiveOption(e,t=!0){e!==this.activeOption&&(this.clearActiveOption(),e&&(this.activeOption=e,le(this.focus_node,{"aria-activedescendant":e.getAttribute("id")}),le(e,{"aria-selected":"true"}),Pe(e,"active"),t&&this.scrollToOption(e)))}scrollToOption(e,t){if(!e)return;let i=this.dropdown_content,r=i.clientHeight,o=i.scrollTop||0,s=e.offsetHeight,a=e.getBoundingClientRect().top-i.getBoundingClientRect().top+o;a+s>r+o?this.scroll(a-r+s,t):a{e.setActiveItemClass(i)}))}inputState(){var e=this;e.control.contains(e.control_input)&&(le(e.control_input,{placeholder:e.settings.placeholder}),e.activeItems.length>0||!e.isFocused&&e.settings.hidePlaceholder&&e.items.length>0?(e.setTextboxValue(),e.isInputHidden=!0):(e.settings.hidePlaceholder&&e.items.length>0&&le(e.control_input,{placeholder:""}),e.isInputHidden=!1),e.wrapper.classList.toggle("input-hidden",e.isInputHidden))}inputValue(){return this.control_input.value.trim()}focus(){var e=this;e.isDisabled||e.isReadOnly||(e.ignoreFocus=!0,e.control_input.offsetWidth?e.control_input.focus():e.focus_node.focus(),setTimeout(()=>{e.ignoreFocus=!1,e.onFocus()},0))}blur(){this.focus_node.blur(),this.onBlur()}getScoreFunction(e){return this.sifter.getScoreFunction(e,this.getSearchOptions())}getSearchOptions(){var e=this.settings,t=e.sortField;return typeof e.sortField=="string"&&(t=[{field:e.sortField}]),{fields:e.searchField,conjunction:e.searchConjunction,sort:t,nesting:e.nesting}}search(e){var t,i,r=this,o=this.getSearchOptions();if(r.settings.score&&(i=r.settings.score.call(r,e),typeof i!="function"))throw new Error('Tom Select "score" setting must be a function that returns a function');return e!==r.lastQuery?(r.lastQuery=e,t=r.sifter.search(e,Object.assign(o,{score:i})),r.currentResults=t):t=Object.assign({},r.currentResults),r.settings.hideSelected&&(t.items=t.items.filter(s=>{let a=We(s.id);return!(a&&r.items.indexOf(a)!==-1)})),t}refreshOptions(e=!0){var t,i,r,o,s,a,l,c,u,d;let p={},y=[];var m=this,v=m.inputValue();let w=v===m.lastQuery||v==""&&m.lastQuery==null;var T=m.search(v),_=null,S=m.settings.shouldOpen||!1,A=m.dropdown_content;w&&(_=m.activeOption,_&&(u=_.closest("[data-group]"))),o=T.items.length,typeof m.settings.maxOptions=="number"&&(o=Math.min(o,m.settings.maxOptions)),o>0&&(S=!0);let K=(L,H)=>{let N=p[L];if(N!==void 0){let $=y[N];if($!==void 0)return[N,$.fragment]}let Y=document.createDocumentFragment();return N=y.length,y.push({fragment:Y,order:H,optgroup:L}),[N,Y]};for(t=0;t0&&($=$.cloneNode(!0),le($,{id:N.$id+"-clone-"+i,"aria-selected":null}),$.classList.add("ts-cloned"),gt($,"active"),m.activeOption&&m.activeOption.dataset.value==H&&u&&u.dataset.group===s.toString()&&(_=$)),Ce.appendChild($),s!=""&&(p[s]=Te)}}m.settings.lockOptgroupOrder&&y.sort((L,H)=>L.order-H.order),l=document.createDocumentFragment(),ge(y,L=>{let H=L.fragment,N=L.optgroup;if(!H||!H.children.length)return;let Y=m.optgroups[N];if(Y!==void 0){let $=document.createDocumentFragment(),ie=m.render("optgroup_header",Y);kt($,ie),kt($,H);let J=m.render("optgroup",{group:Y,options:$});kt(l,J)}else kt(l,H)}),A.innerHTML="",kt(A,l),m.settings.highlight&&(Za(A),T.query.length&&T.tokens.length&&ge(T.tokens,L=>{Ja(A,L.regex)}));var z=L=>{let H=m.render(L,{input:v});return H&&(S=!0,A.insertBefore(H,A.firstChild)),H};if(m.loading?z("loading"):m.settings.shouldLoad.call(m,v)?T.items.length===0&&z("no_results"):z("not_loading"),c=m.canCreate(v),c&&(d=z("option_create")),m.hasOptions=T.items.length>0||c,S){if(T.items.length>0){if(!_&&m.settings.mode==="single"&&m.items[0]!=null&&(_=m.getOption(m.items[0])),!A.contains(_)){let L=0;d&&!m.settings.addPrecedence&&(L=1),_=m.selectable()[L]}}else d&&(_=d);e&&!m.isOpen&&(m.open(),m.scrollToOption(_,"auto")),m.setActiveOption(_)}else m.clearActiveOption(),e&&m.isOpen&&m.close(!1)}selectable(){return this.dropdown_content.querySelectorAll("[data-selectable]")}addOption(e,t=!1){let i=this;if(Array.isArray(e))return i.addOptions(e,t),!1;let r=We(e[i.settings.valueField]);return r===null||i.options.hasOwnProperty(r)?!1:(e.$order=e.$order||++i.order,e.$id=i.inputId+"-opt-"+e.$order,i.options[r]=e,i.lastQuery=null,t&&(i.userOptions[r]=t,i.trigger("option_add",r,e)),r)}addOptions(e,t=!1){ge(e,i=>{this.addOption(i,t)})}registerOption(e){return this.addOption(e)}registerOptionGroup(e){var t=We(e[this.settings.optgroupValueField]);return t===null?!1:(e.$order=e.$order||++this.order,this.optgroups[t]=e,t)}addOptionGroup(e,t){var i;t[this.settings.optgroupValueField]=e,(i=this.registerOptionGroup(t))&&this.trigger("optgroup_add",i,t)}removeOptionGroup(e){this.optgroups.hasOwnProperty(e)&&(delete this.optgroups[e],this.clearCache(),this.trigger("optgroup_remove",e))}clearOptionGroups(){this.optgroups={},this.clearCache(),this.trigger("optgroup_clear")}updateOption(e,t){let i=this;var r,o;let s=We(e),a=We(t[i.settings.valueField]);if(s===null)return;let l=i.options[s];if(l==null)return;if(typeof a!="string")throw new Error("Value must be set in option data");let c=i.getOption(s),u=i.getItem(s);if(t.$order=t.$order||l.$order,delete i.options[s],i.uncacheValue(a),i.options[a]=t,c){if(i.dropdown_content.contains(c)){let d=i._render("option",t);di(c,d),i.activeOption===c&&i.setActiveOption(d)}c.remove()}u&&(o=i.items.indexOf(s),o!==-1&&i.items.splice(o,1,a),r=i._render("item",t),u.classList.contains("active")&&Pe(r,"active"),di(u,r)),i.lastQuery=null}removeOption(e,t){let i=this;e=ci(e),i.uncacheValue(e),delete i.userOptions[e],delete i.options[e],i.lastQuery=null,i.trigger("option_remove",e),i.removeItem(e,t)}clearOptions(e){let t=(e||this.clearFilter).bind(this);this.loadedSearches={},this.userOptions={},this.clearCache();let i={};ge(this.options,(r,o)=>{t(r,o)&&(i[o]=r)}),this.options=this.sifter.items=i,this.lastQuery=null,this.trigger("option_clear")}clearFilter(e,t){return this.items.indexOf(t)>=0}getOption(e,t=!1){let i=We(e);if(i===null)return null;let r=this.options[i];if(r!=null){if(r.$div)return r.$div;if(t)return this._render("option",r)}return null}getAdjacent(e,t,i="option"){var r=this,o;if(!e)return null;i=="item"?o=r.controlChildren():o=r.dropdown_content.querySelectorAll("[data-selectable]");for(let s=0;s0?o[s+1]:o[s-1];return null}getItem(e){if(typeof e=="object")return e;var t=We(e);return t!==null?this.control.querySelector(`[data-value="${$o(t)}"]`):null}addItems(e,t){var i=this,r=Array.isArray(e)?e:[e];r=r.filter(s=>i.items.indexOf(s)===-1);let o=r[r.length-1];r.forEach(s=>{i.isPending=s!==o,i.addItem(s,t)})}addItem(e,t){var i=t?[]:["change","dropdown_close"];Fo(this,i,()=>{var r,o;let s=this,a=s.settings.mode,l=We(e);if(!(l&&s.items.indexOf(l)!==-1&&(a==="single"&&s.close(),a==="single"||!s.settings.duplicates))&&!(l===null||!s.options.hasOwnProperty(l))&&(a==="single"&&s.clear(t),!(a==="multi"&&s.isFull()))){if(r=s._render("item",s.options[l]),s.control.contains(r)&&(r=r.cloneNode(!0)),o=s.isFull(),s.items.splice(s.caretPos,0,l),s.insertAtCaret(r),s.isSetup){if(!s.isPending&&s.settings.hideSelected){let c=s.getOption(l),u=s.getAdjacent(c,1);u&&s.setActiveOption(u)}!s.isPending&&!s.settings.closeAfterSelect&&s.refreshOptions(s.isFocused&&a!=="single"),s.settings.closeAfterSelect!=!1&&s.isFull()?s.close():s.isPending||s.positionDropdown(),s.trigger("item_add",l,r),s.isPending||s.updateOriginalInput({silent:t})}(!s.isPending||!o&&s.isFull())&&(s.inputState(),s.refreshState())}})}removeItem(e=null,t){let i=this;if(e=i.getItem(e),!e)return;var r,o;let s=e.dataset.value;r=zo(e),e.remove(),e.classList.contains("active")&&(o=i.activeItems.indexOf(e),i.activeItems.splice(o,1),gt(e,"active")),i.items.splice(r,1),i.lastQuery=null,!i.settings.persist&&i.userOptions.hasOwnProperty(s)&&i.removeOption(s,t),r{}){arguments.length===3&&(t=arguments[2]),typeof t!="function"&&(t=()=>{});var i=this,r=i.caretPos,o;if(e=e||i.inputValue(),!i.canCreate(e))return t(),!1;i.lock();var s=!1,a=l=>{if(i.unlock(),!l||typeof l!="object")return t();var c=We(l[i.settings.valueField]);if(typeof c!="string")return t();i.setTextboxValue(),i.addOption(l,!0),i.setCaret(r),i.addItem(c),t(l),s=!0};return typeof i.settings.create=="function"?o=i.settings.create.call(this,e,a):o={[i.settings.labelField]:e,[i.settings.valueField]:e},s||a(o),!0}refreshItems(){var e=this;e.lastQuery=null,e.isSetup&&e.addItems(e.items),e.updateOriginalInput(),e.refreshState()}refreshState(){let e=this;e.refreshValidityState();let t=e.isFull(),i=e.isLocked;e.wrapper.classList.toggle("rtl",e.rtl);let r=e.wrapper.classList;r.toggle("focus",e.isFocused),r.toggle("disabled",e.isDisabled),r.toggle("readonly",e.isReadOnly),r.toggle("required",e.isRequired),r.toggle("invalid",!e.isValid),r.toggle("locked",i),r.toggle("full",t),r.toggle("input-active",e.isFocused&&!e.isInputHidden),r.toggle("dropdown-active",e.isOpen),r.toggle("has-options",Qa(e.options)),r.toggle("has-items",e.items.length>0)}refreshValidityState(){var e=this;e.input.validity&&(e.isValid=e.input.validity.valid,e.isInvalid=!e.isValid)}isFull(){return this.settings.maxItems!==null&&this.items.length>=this.settings.maxItems}updateOriginalInput(e={}){let t=this;var i,r;let o=t.input.querySelector('option[value=""]');if(t.is_select_tag){let l=function(c,u,d){return c||(c=nt('")),c!=o&&t.input.append(c),s.push(c),(c!=o||a>0)&&(c.selected=!0),c},s=[],a=t.input.querySelectorAll("option:checked").length;t.input.querySelectorAll("option:checked").forEach(c=>{c.selected=!1}),t.items.length==0&&t.settings.mode=="single"?l(o,"",""):t.items.forEach(c=>{if(i=t.options[c],r=i[t.settings.labelField]||"",s.includes(i.$option)){let u=t.input.querySelector(`option[value="${$o(c)}"]:not(:checked)`);l(u,c,r)}else i.$option=l(i.$option,c,r)})}else t.input.value=t.getValue();t.isSetup&&(e.silent||t.trigger("change",t.getValue()))}open(){var e=this;e.isLocked||e.isOpen||e.settings.mode==="multi"&&e.isFull()||(e.isOpen=!0,le(e.focus_node,{"aria-expanded":"true"}),e.refreshState(),ui(e.dropdown,{visibility:"hidden",display:"block"}),e.positionDropdown(),ui(e.dropdown,{visibility:"visible",display:"block"}),e.focus(),e.trigger("dropdown_open",e.dropdown))}close(e=!0){var t=this,i=t.isOpen;e&&(t.setTextboxValue(),t.settings.mode==="single"&&t.items.length&&t.inputState()),t.isOpen=!1,le(t.focus_node,{"aria-expanded":"false"}),ui(t.dropdown,{display:"none"}),t.settings.hideSelected&&t.clearActiveOption(),t.refreshState(),i&&t.trigger("dropdown_close",t.dropdown)}positionDropdown(){if(this.settings.dropdownParent==="body"){var e=this.control,t=e.getBoundingClientRect(),i=e.offsetHeight+t.top+window.scrollY,r=t.left+window.scrollX;ui(this.dropdown,{width:t.width+"px",top:i+"px",left:r+"px"})}}clear(e){var t=this;if(t.items.length){var i=t.controlChildren();ge(i,r=>{t.removeItem(r,!0)}),t.inputState(),e||t.updateOriginalInput(),t.trigger("clear")}}insertAtCaret(e){let t=this,i=t.caretPos,r=t.control;r.insertBefore(e,r.children[i]||null),t.setCaret(i+1)}deleteSelection(e){var t,i,r,o,s=this;t=e&&e.keyCode===8?-1:1,i=Ya(s.control_input);let a=[];if(s.activeItems.length)o=Vo(s.activeItems,t),r=zo(o),t>0&&r++,ge(s.activeItems,l=>a.push(l));else if((s.isFocused||s.settings.mode==="single")&&s.items.length){let l=s.controlChildren(),c;t<0&&i.start===0&&i.length===0?c=l[s.caretPos-1]:t>0&&i.start===s.inputValue().length&&(c=l[s.caretPos]),c!==void 0&&a.push(c)}if(!s.shouldDelete(a,e))return!1;for(fe(e,!0),typeof r!="undefined"&&s.setCaret(r);a.length;)s.removeItem(a.pop());return s.inputState(),s.positionDropdown(),s.refreshOptions(!1),!0}shouldDelete(e,t){let i=e.map(r=>r.dataset.value);return!(!i.length||typeof this.settings.onDelete=="function"&&this.settings.onDelete(i,t)===!1)}advanceSelection(e,t){var i,r,o=this;o.rtl&&(e*=-1),!o.inputValue().length&&(Mt(fi,t)||Mt("shiftKey",t)?(i=o.getLastActive(e),i?i.classList.contains("active")?r=o.getAdjacent(i,e,"item"):r=i:e>0?r=o.control_input.nextElementSibling:r=o.control_input.previousElementSibling,r&&(r.classList.contains("active")&&o.removeActiveItem(i),o.setActiveItemClass(r))):o.moveCaret(e))}moveCaret(e){}getLastActive(e){let t=this.control.querySelector(".last-active");if(t)return t;var i=this.control.querySelectorAll(".active");if(i)return Vo(i,e)}setCaret(e){this.caretPos=this.items.length}controlChildren(){return Array.from(this.control.querySelectorAll("[data-ts-item]"))}lock(){this.setLocked(!0)}unlock(){this.setLocked(!1)}setLocked(e=this.isReadOnly||this.isDisabled){this.isLocked=e,this.refreshState()}disable(){this.setDisabled(!0),this.close()}enable(){this.setDisabled(!1)}setDisabled(e){this.focus_node.tabIndex=e?-1:this.tabIndex,this.isDisabled=e,this.input.disabled=e,this.control_input.disabled=e,this.setLocked()}setReadOnly(e){this.isReadOnly=e,this.input.readOnly=e,this.control_input.readOnly=e,this.setLocked()}destroy(){var e=this,t=e.revertSettings;e.trigger("destroy"),e.off(),e.wrapper.remove(),e.dropdown.remove(),e.input.innerHTML=t.innerHTML,e.input.tabIndex=t.tabIndex,gt(e.input,"tomselected","ts-hidden-accessible"),e._destroy(),delete e.input.tomselect}render(e,t){var i,r;let o=this;if(typeof this.settings.render[e]!="function"||(r=o.settings.render[e].call(this,t,ur),!r))return null;if(r=nt(r),e==="option"||e==="option_create"?t[o.settings.disabledField]?le(r,{"aria-disabled":"true"}):le(r,{"data-selectable":""}):e==="optgroup"&&(i=t.group[o.settings.optgroupValueField],le(r,{"data-group":i}),t.group[o.settings.disabledField]&&le(r,{"data-disabled":""})),e==="option"||e==="item"){let s=ci(t[o.settings.valueField]);le(r,{"data-value":s}),e==="item"?(Pe(r,o.settings.itemClass),le(r,{"data-ts-item":""})):(Pe(r,o.settings.optionClass),le(r,{role:"option",id:t.$id}),t.$div=r,o.options[s]=t)}return r}_render(e,t){let i=this.render(e,t);if(i==null)throw"HTMLElement expected";return i}clearCache(){ge(this.options,e=>{e.$div&&(e.$div.remove(),delete e.$div)})}uncacheValue(e){let t=this.getOption(e);t&&t.remove()}canCreate(e){return this.settings.create&&e.length>0&&this.settings.createFilter.call(this,e)}hook(e,t,i){var r=this,o=r[t];r[t]=function(){var s,a;return e==="after"&&(s=o.apply(r,arguments)),a=i.apply(r,arguments),e==="instead"?a:(e==="before"&&(s=o.apply(r,arguments)),s)}}};var _m=(n,e,t,i)=>{n.addEventListener(e,t,i)};function il(){_m(this.input,"change",()=>{this.sync()})}var wm=n=>typeof n=="undefined"||n===null?null:xm(n),xm=n=>typeof n=="boolean"?n?"1":"0":n+"",rl=(n,e=!1)=>{n&&(n.preventDefault(),e&&n.stopPropagation())},Tm=n=>{if(n.jquery)return n[0];if(n instanceof HTMLElement)return n;if(Cm(n)){var e=document.createElement("template");return e.innerHTML=n.trim(),e.content.firstChild}return document.querySelector(n)},Cm=n=>typeof n=="string"&&n.indexOf("<")>-1;function ol(n){var e=this,t=e.onOptionSelect;e.settings.hideSelected=!1;let i=Object.assign({className:"tomselect-checkbox",checkedClassNames:void 0,uncheckedClassNames:void 0},n);var r=function(a,l){l?(a.checked=!0,i.uncheckedClassNames&&a.classList.remove(...i.uncheckedClassNames),i.checkedClassNames&&a.classList.add(...i.checkedClassNames)):(a.checked=!1,i.checkedClassNames&&a.classList.remove(...i.checkedClassNames),i.uncheckedClassNames&&a.classList.add(...i.uncheckedClassNames))},o=function(a){setTimeout(()=>{var l=a.querySelector("input."+i.className);l instanceof HTMLInputElement&&r(l,a.classList.contains("selected"))},1)};e.hook("after","setupTemplates",()=>{var s=e.settings.render.option;e.settings.render.option=(a,l)=>{var c=Tm(s.call(e,a,l)),u=document.createElement("input");i.className&&u.classList.add(i.className),u.addEventListener("click",function(p){rl(p)}),u.type="checkbox";let d=wm(a[e.settings.valueField]);return r(u,!!(d&&e.items.indexOf(d)>-1)),c.prepend(u),c}}),e.on("item_remove",s=>{var a=e.getOption(s);a&&(a.classList.remove("selected"),o(a))}),e.on("item_add",s=>{var a=e.getOption(s);a&&o(a)}),e.hook("instead","onOptionSelect",(s,a)=>{if(a.classList.contains("selected")){a.classList.remove("selected"),e.removeItem(a.dataset.value),e.refreshOptions(),rl(s,!0);return}t.call(e,s,a),o(a)})}var Am=n=>{if(n.jquery)return n[0];if(n instanceof HTMLElement)return n;if(Sm(n)){var e=document.createElement("template");return e.innerHTML=n.trim(),e.content.firstChild}return document.querySelector(n)},Sm=n=>typeof n=="string"&&n.indexOf("<")>-1;function sl(n){let e=this,t=Object.assign({className:"clear-button",title:"Clear All",html:i=>`
`},n);e.on("initialize",()=>{var i=Am(t.html(t));i.addEventListener("click",r=>{e.isLocked||(e.clear(),e.settings.mode==="single"&&e.settings.allowEmptyOption&&e.addItem(""),r.preventDefault(),r.stopPropagation())}),e.control.appendChild(i)})}var Dm=(n,e=!1)=>{n&&(n.preventDefault(),e&&n.stopPropagation())},Dn=(n,e,t,i)=>{n.addEventListener(e,t,i)},Om=(n,e)=>{if(Array.isArray(n))n.forEach(e);else for(var t in n)n.hasOwnProperty(t)&&e(n[t],t)},Lm=n=>{if(n.jquery)return n[0];if(n instanceof HTMLElement)return n;if(Mm(n)){var e=document.createElement("template");return e.innerHTML=n.trim(),e.content.firstChild}return document.querySelector(n)},Mm=n=>typeof n=="string"&&n.indexOf("<")>-1,km=(n,e)=>{Om(e,(t,i)=>{t==null?n.removeAttribute(i):n.setAttribute(i,""+t)})},Nm=(n,e)=>{var t;(t=n.parentNode)==null||t.insertBefore(e,n.nextSibling)},Im=(n,e)=>{var t;(t=n.parentNode)==null||t.insertBefore(e,n)},Rm=(n,e)=>{do{var t;if(e=(t=e)==null?void 0:t.previousElementSibling,n==e)return!0}while(e&&e.previousElementSibling);return!1};function al(){var n=this;if(n.settings.mode!=="multi")return;var e=n.lock,t=n.unlock;let i=!0,r;n.hook("after","setupTemplates",()=>{var o=n.settings.render.item;n.settings.render.item=(s,a)=>{let l=Lm(o.call(n,s,a));km(l,{draggable:"true"});let c=v=>{i||Dm(v),v.stopPropagation()},u=v=>{r=l,setTimeout(()=>{l.classList.add("ts-dragging")},0)},d=v=>{v.preventDefault(),l.classList.add("ts-drag-over"),y(l,r)},p=()=>{l.classList.remove("ts-drag-over")},y=(v,w)=>{w!==void 0&&(Rm(w,l)?Nm(v,w):Im(v,w))},m=()=>{var v;document.querySelectorAll(".ts-drag-over").forEach(T=>T.classList.remove("ts-drag-over")),(v=r)==null||v.classList.remove("ts-dragging"),r=void 0;var w=[];n.control.querySelectorAll("[data-value]").forEach(T=>{if(T.dataset.value){let _=T.dataset.value;_&&w.push(_)}}),n.setValue(w)};return Dn(l,"mousedown",c),Dn(l,"dragstart",u),Dn(l,"dragenter",d),Dn(l,"dragover",d),Dn(l,"dragleave",p),Dn(l,"dragend",m),l}}),n.hook("instead","lock",()=>(i=!1,e.call(n))),n.hook("instead","unlock",()=>(i=!0,t.call(n)))}var Hm=(n,e=!1)=>{n&&(n.preventDefault(),e&&n.stopPropagation())},Pm=n=>{if(n.jquery)return n[0];if(n instanceof HTMLElement)return n;if(Fm(n)){var e=document.createElement("template");return e.innerHTML=n.trim(),e.content.firstChild}return document.querySelector(n)},Fm=n=>typeof n=="string"&&n.indexOf("<")>-1;function ll(n){let e=this,t=Object.assign({title:"Untitled",headerClass:"dropdown-header",titleRowClass:"dropdown-header-title",labelClass:"dropdown-header-label",closeClass:"dropdown-header-close",html:i=>'
'+i.title+'×
'},n);e.on("initialize",()=>{var i=Pm(t.html(t)),r=i.querySelector("."+t.closeClass);r&&r.addEventListener("click",o=>{Hm(o,!0),e.close()}),e.dropdown.insertBefore(i,e.dropdown.firstChild)})}var $m=(n,e)=>{if(Array.isArray(n))n.forEach(e);else for(var t in n)n.hasOwnProperty(t)&&e(n[t],t)},Bm=(n,...e)=>{var t=Vm(e);n=zm(n),n.map(i=>{t.map(r=>{i.classList.remove(r)})})},Vm=n=>{var e=[];return $m(n,t=>{typeof t=="string"&&(t=t.trim().split(/[\t\n\f\r\s]/)),Array.isArray(t)&&(e=e.concat(t))}),e.filter(Boolean)},zm=n=>(Array.isArray(n)||(n=[n]),n),jm=(n,e)=>{if(!n)return-1;e=e||n.nodeName;for(var t=0;n=n.previousElementSibling;)n.matches(e)&&t++;return t};function cl(){var n=this;n.hook("instead","setCaret",e=>{n.settings.mode==="single"||!n.control.contains(n.control_input)?e=n.items.length:(e=Math.max(0,Math.min(n.items.length,e)),e!=n.caretPos&&!n.isPending&&n.controlChildren().forEach((t,i)=>{i{if(!n.isFocused)return;let t=n.getLastActive(e);if(t){let i=jm(t);n.setCaret(e>0?i+1:i),n.setActiveItem(),Bm(t,"last-active")}else n.setCaret(n.caretPos+e)})}var Wm=(n,e=!1)=>{n&&(n.preventDefault(),e&&n.stopPropagation())},qm=(n,e,t,i)=>{n.addEventListener(e,t,i)},Um=(n,e)=>{if(Array.isArray(n))n.forEach(e);else for(var t in n)n.hasOwnProperty(t)&&e(n[t],t)},ul=n=>{if(n.jquery)return n[0];if(n instanceof HTMLElement)return n;if(Ym(n)){var e=document.createElement("template");return e.innerHTML=n.trim(),e.content.firstChild}return document.querySelector(n)},Ym=n=>typeof n=="string"&&n.indexOf("<")>-1,Gm=(n,...e)=>{var t=Km(e);n=Xm(n),n.map(i=>{t.map(r=>{i.classList.add(r)})})},Km=n=>{var e=[];return Um(n,t=>{typeof t=="string"&&(t=t.trim().split(/[\t\n\f\r\s]/)),Array.isArray(t)&&(e=e.concat(t))}),e.filter(Boolean)},Xm=n=>(Array.isArray(n)||(n=[n]),n);function dl(){let n=this;n.settings.shouldOpen=!0,n.hook("before","setup",()=>{n.focus_node=n.control,Gm(n.control_input,"dropdown-input");let e=ul('