diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index b0b8c02ad..3d2038b22 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -1,7 +1,7 @@ --- name: 🐛 Bug Report description: Report a reproducible bug in the current release of NetBox -labels: ["type: bug"] +labels: ["type: bug", "status: needs triage"] body: - type: markdown attributes: @@ -17,15 +17,16 @@ body: How are you running NetBox? (For issues with the Docker image, please go to the [netbox-docker](https://github.com/netbox-community/netbox-docker) repo.) options: - - Self-hosted - NetBox Cloud + - NetBox Enterprise + - Self-hosted validations: required: true - type: input attributes: label: NetBox Version description: What version of NetBox are you currently running? - placeholder: v3.7.3 + placeholder: v4.0.3 validations: required: true - type: dropdown @@ -33,10 +34,9 @@ body: label: Python Version description: What version of Python are you currently running? options: - - "3.8" - - "3.9" - "3.10" - "3.11" + - "3.12" validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/documentation_change.yaml b/.github/ISSUE_TEMPLATE/documentation_change.yaml index cb097d579..b5a970782 100644 --- a/.github/ISSUE_TEMPLATE/documentation_change.yaml +++ b/.github/ISSUE_TEMPLATE/documentation_change.yaml @@ -1,7 +1,7 @@ --- name: 📖 Documentation Change description: Suggest an addition or modification to the NetBox documentation -labels: ["type: documentation"] +labels: ["type: documentation", "status: needs triage"] body: - type: dropdown attributes: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index bc99999c0..bd9a17ff9 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -1,7 +1,7 @@ --- name: ✨ Feature Request description: Propose a new NetBox feature or enhancement -labels: ["type: feature"] +labels: ["type: feature", "status: needs triage"] body: - type: markdown attributes: @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.7.3 + placeholder: v4.0.3 validations: required: true - type: dropdown diff --git a/.github/workflows/auto-assign-issue.yml b/.github/workflows/auto-assign-issue.yml new file mode 100644 index 000000000..4e93d9f0d --- /dev/null +++ b/.github/workflows/auto-assign-issue.yml @@ -0,0 +1,21 @@ +# auto-assign-issue (https://github.com/marketplace/actions/auto-assign-issue) +name: Issue assignment + +on: + issues: + types: [opened] + +permissions: + issues: write + +jobs: + auto-assign: + runs-on: ubuntu-latest + steps: + - uses: pozil/auto-assign-issue@v1 + if: "contains(github.event.issue.labels.*.name, 'status: needs triage')" + with: + # Weighted assignments + assignees: arthanson:3, jeffgdotorg:3, jeremystretch:3, DanSheps + numOfAssignee: 1 + abortIfPreviousAssignees: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed8c65b7d..b32f519bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,18 @@ name: CI -on: [push, pull_request] + +on: + push: + paths-ignore: + - 'contrib/**' + - 'docs/**' + pull_request: + paths-ignore: + - 'contrib/**' + - 'docs/**' + permissions: contents: read + jobs: build: runs-on: ubuntu-latest @@ -9,8 +20,8 @@ jobs: NETBOX_CONFIGURATION: netbox.configuration_testing strategy: matrix: - python-version: ['3.8', '3.9', '3.10', '3.11'] - node-version: ['14.x'] + python-version: ['3.10', '3.11', '3.12'] + node-version: ['18.x'] services: redis: image: redis @@ -84,4 +95,4 @@ jobs: run: coverage run --source="netbox/" netbox/manage.py test netbox/ --parallel - name: Show coverage report - run: coverage report --skip-covered --omit *migrations* + run: coverage report --skip-covered --omit '*/migrations/*,*/tests/*' diff --git a/.github/workflows/close-incomplete-issues.yml b/.github/workflows/close-incomplete-issues.yml new file mode 100644 index 000000000..4d31d735e --- /dev/null +++ b/.github/workflows/close-incomplete-issues.yml @@ -0,0 +1,32 @@ +# close-stale-issues (https://github.com/marketplace/actions/close-stale-issues) +name: Close incomplete issues + +on: + schedule: + - cron: '15 4 * * *' + workflow_dispatch: + +permissions: + actions: write + issues: write + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v9 + with: + close-issue-message: > + This issue is being closed as no further information has been provided. If + you would like to revisit this topic, please first modify your original post + to include all the requested detail, and then ask that the issue be reopened. + days-before-stale: 7 + days-before-close: 7 + only-issue-labels: 'status: revisions needed' + operations-per-run: 100 + remove-stale-when-updated: false + stale-issue-label: 'pending closure' + stale-issue-message: > + This is a reminder that additional information is needed in order to further + triage this issue. If the requested details are not provided, the issue will + soon be closed automatically. diff --git a/.github/workflows/stale.yml b/.github/workflows/close-stale-issues.yml similarity index 81% rename from .github/workflows/stale.yml rename to .github/workflows/close-stale-issues.yml index 22de146a2..b02ffdacd 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/close-stale-issues.yml @@ -1,5 +1,5 @@ # close-stale-issues (https://github.com/marketplace/actions/close-stale-issues) -name: 'Close stale issues/PRs' +name: Close stale issues/PRs on: schedule: @@ -7,28 +7,29 @@ on: workflow_dispatch: permissions: + actions: write issues: write pull-requests: write jobs: stale: - runs-on: ubuntu-latest steps: - - uses: actions/stale@v8 + - uses: actions/stale@v9 with: + # General parameters + operations-per-run: 100 + remove-stale-when-updated: false + + # Issue parameters close-issue-message: > This issue has been automatically closed due to lack of activity. In an effort to reduce noise, please do not comment any further. Note that the core maintainers may elect to reopen this issue at a later date if deemed necessary. - close-pr-message: > - This PR has been automatically closed due to lack of activity. - days-before-stale: 90 - days-before-close: 30 - exempt-issue-labels: 'status: accepted,status: blocked,status: needs milestone' - operations-per-run: 100 - remove-stale-when-updated: false + days-before-issue-stale: 90 + days-before-issue-close: 30 + exempt-issue-labels: 'status: accepted,status: backlog,status: blocked' stale-issue-label: 'pending closure' stale-issue-message: > This issue has been automatically marked as stale because it has not had @@ -38,6 +39,12 @@ jobs: process by "bumping" the issue; doing so will result in its immediate closure and you may be barred from participating in any future discussions. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md). + + # Pull request parameters + close-pr-message: > + This PR has been automatically closed due to lack of activity. + days-before-pr-stale: 15 + days-before-pr-close: 15 stale-pr-label: 'pending closure' stale-pr-message: > This PR has been automatically marked as stale because it has not had diff --git a/.github/workflows/lock.yml b/.github/workflows/lock-threads.yml similarity index 95% rename from .github/workflows/lock.yml rename to .github/workflows/lock-threads.yml index ad3bf5d75..0f3636784 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock-threads.yml @@ -1,5 +1,5 @@ # lock-threads (https://github.com/marketplace/actions/lock-threads) -name: 'Lock threads' +name: Lock threads on: schedule: diff --git a/.github/workflows/update-translation-strings.yml b/.github/workflows/update-translation-strings.yml new file mode 100644 index 000000000..5b0c09300 --- /dev/null +++ b/.github/workflows/update-translation-strings.yml @@ -0,0 +1,45 @@ +name: Update translation strings + +on: + schedule: + - cron: '0 5 * * *' + workflow_dispatch: + +permissions: + contents: write + +env: + LOCALE: "en" + +jobs: + makemessages: + runs-on: ubuntu-latest + env: + NETBOX_CONFIGURATION: netbox.configuration_testing + + steps: + - name: Check out repo + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: 3.11 + + - name: Install system dependencies + run: sudo apt install -y gettext + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run makemessages + run: python netbox/manage.py makemessages -l ${{ env.LOCALE }} + + - name: Commit changes + uses: EndBug/add-and-commit@v9 + with: + add: 'netbox/translations/' + default_author: github_actions + message: 'Update source translation strings' diff --git a/.gitignore b/.gitignore index 93954fd41..88faab27c 100644 --- a/.gitignore +++ b/.gitignore @@ -17,9 +17,11 @@ yarn-error.log* /venv/ /*.sh local_requirements.txt +local_settings.py !upgrade.sh fabfile.py gunicorn.py +uwsgi.ini netbox.log netbox.pid .DS_Store diff --git a/.readthedocs.yaml b/.readthedocs.yaml index bb42ca19f..c69c51748 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,8 +1,8 @@ version: 2 build: - os: ubuntu-20.04 + os: ubuntu-22.04 tools: - python: "3.9" + python: "3.12" mkdocs: configuration: mkdocs.yml python: diff --git a/README.md b/README.md index a81e41854..4d21003b5 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ License Contributors GitHub stars - Languages supported + Languages supported CI status

@@ -84,7 +84,7 @@ NetBox automatically logs the creation, modification, and deletion of all manage

NetBox Cloud
- Looking for an enterprise solution? Check out NetBox Cloud! + Looking for a managed solution? Check out NetBox Cloud or NetBox Enterprise!

## Get Involved @@ -95,16 +95,6 @@ NetBox automatically logs the creation, modification, and deletion of all manage * Contributions from the community are encouraged and appreciated! Check out our [contributing guide](CONTRIBUTING.md) to get started. * [Share your idea](https://plugin-ideas.netbox.dev/) for a new plugin, or [learn how to build one](https://github.com/netbox-community/netbox-plugin-tutorial) yourself! -## Project Stats - -

- Timeline graph - Issues graph - Pull requests graph - Top contributors -
Stats via Repography -

- ## Screenshots

diff --git a/base_requirements.txt b/base_requirements.txt index de885eeb2..9912f1d6b 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -1,10 +1,6 @@ -# HTML sanitizer -# https://github.com/mozilla/bleach/blob/main/CHANGES -bleach - # The Python web framework on which NetBox is built # https://docs.djangoproject.com/en/stable/releases/ -Django<5.0 +Django<5.1 # Django middleware which permits cross-domain API requests # https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst @@ -18,14 +14,13 @@ django-debug-toolbar # https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst django-filter -# Django debug toolbar extension with support for GraphiQL -# https://github.com/flavors/django-graphiql-debug-toolbar/blob/main/CHANGES.rst -django-graphiql-debug-toolbar +# HTMX utilities for Django +# https://django-htmx.readthedocs.io/en/latest/changelog.html +django-htmx # Modified Preorder Tree Traversal (recursive nesting of objects) -# Pinned to 0.14.0; 0.15.0 requires Python 3.9+ # https://github.com/django-mptt/django-mptt/blob/main/CHANGELOG.rst -django-mptt==0.14.0 +django-mptt # Context managers for PostgreSQL advisory locks # https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt @@ -75,11 +70,6 @@ drf-spectacular-sidecar # https://github.com/kurtmckee/feedparser/blob/develop/CHANGELOG.rst feedparser -# Django wrapper for Graphene (GraphQL support) -# https://github.com/graphql-python/graphene-django/releases -# Pinned to v3.0.0 for GraphiQL UI issue (see #12762) -graphene_django==3.0.0 - # WSGI HTTP server # https://docs.gunicorn.org/en/latest/news.html gunicorn @@ -101,13 +91,17 @@ markdown-include mkdocs-material # Introspection for embedded code -# https://github.com/mkdocstrings/mkdocstrings/blob/master/CHANGELOG.md +# https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md mkdocstrings[python-legacy] # Library for manipulating IP prefixes and addresses # https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst netaddr +# Python bindings to the ammonia HTML sanitization library. +# https://github.com/messense/nh3 +nh3 + # Fork of PIL (Python Imaging Library) for image processing # https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst Pillow @@ -132,8 +126,16 @@ social-auth-core # https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md social-auth-app-django +# Strawberry GraphQL +# https://github.com/strawberry-graphql/strawberry/blob/main/CHANGELOG.md +strawberry-graphql + +# Strawberry GraphQL Django extension +# https://github.com/strawberry-graphql/strawberry-django/releases +strawberry-graphql-django + # SVG image rendering (used for rack elevations) -# hhttps://github.com/mozman/svgwrite/blob/master/NEWS.rst +# https://github.com/mozman/svgwrite/blob/master/NEWS.rst svgwrite # Tabular dataset library (for table-based exports) diff --git a/contrib/generated_schema.json b/contrib/generated_schema.json index 5e8507798..5cfdfd9d0 100644 --- a/contrib/generated_schema.json +++ b/contrib/generated_schema.json @@ -1,5 +1,7 @@ { "type": "object", + "$id": "urn:devicetype-library:generated-schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "definitions": { "airflow": { @@ -177,6 +179,9 @@ "usb-micro-ab", "usb-3-b", "usb-3-micro-b", + "molex-micro-fit-1x2", + "molex-micro-fit-2x2", + "molex-micro-fit-2x4", "dc-terminal", "saf-d-grid", "neutrik-powercon-20", @@ -279,6 +284,9 @@ "usb-a", "usb-micro-b", "usb-c", + "molex-micro-fit-1x2", + "molex-micro-fit-2x2", + "molex-micro-fit-2x4", "dc-terminal", "hdot-cx", "saf-d-grid", @@ -351,6 +359,8 @@ "800gbase-x-qsfpdd", "800gbase-x-osfp", "1000base-kx", + "2.5gbase-kx", + "5gbase-kr", "10gbase-kr", "10gbase-kx4", "25gbase-kr", @@ -371,6 +381,8 @@ "gsm", "cdma", "lte", + "4g", + "5g", "sonet-oc3", "sonet-oc12", "sonet-oc48", @@ -384,7 +396,10 @@ "8gfc-sfpp", "16gfc-sfpp", "32gfc-sfp28", + "32gfc-sfpp", "64gfc-qsfpp", + "64gfc-sfpdd", + "64gfc-sfpp", "128gfc-qsfp28", "infiniband-sdr", "infiniband-ddr", @@ -401,12 +416,15 @@ "e3", "xdsl", "docsis", + "bpon", + "epon", + "10g-epon", "gpon", "xg-pon", "xgs-pon", "ng-pon2", - "epon", - "10g-epon", + "25g-pon", + "50g-pon", "cisco-stackwise", "cisco-stackwise-plus", "cisco-flexstack", diff --git a/contrib/gunicorn.py b/contrib/gunicorn.py index 89d6943b4..4b2b7c6b0 100644 --- a/contrib/gunicorn.py +++ b/contrib/gunicorn.py @@ -14,3 +14,7 @@ timeout = 120 # The maximum number of requests a worker can handle before being respawned max_requests = 5000 max_requests_jitter = 500 + +# Uncomment this line to accept HTTP headers containing underscores, e.g. for remote +# authentication support. See https://docs.gunicorn.org/en/stable/settings.html#header-map +# header-map = 'dangerous' diff --git a/contrib/netbox.service b/contrib/netbox.service index 3cd02d988..8c602fa5b 100644 --- a/contrib/netbox.service +++ b/contrib/netbox.service @@ -12,8 +12,12 @@ Group=netbox PIDFile=/var/tmp/netbox.pid WorkingDirectory=/opt/netbox +# Remove the following line if using uWSGI instead of Gunicorn ExecStart=/opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox --config /opt/netbox/gunicorn.py netbox.wsgi +# Uncomment the following line if using uWSGI instead of Gunicorn +#ExecStart=/opt/netbox/venv/bin/uwsgi --ini /opt/netbox/uwsgi.ini + Restart=on-failure RestartSec=30 PrivateTmp=true diff --git a/contrib/nginx.conf b/contrib/nginx.conf index 34821cd52..31d026e0d 100644 --- a/contrib/nginx.conf +++ b/contrib/nginx.conf @@ -14,10 +14,20 @@ server { } location / { + # Remove these lines if using uWSGI instead of Gunicorn proxy_pass http://127.0.0.1:8001; proxy_set_header X-Forwarded-Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; + + # Uncomment these lines if using uWSGI instead of Gunicorn + # include uwsgi_params; + # uwsgi_pass 127.0.0.1:8001; + # uwsgi_param Host $host; + # uwsgi_param X-Real-IP $remote_addr; + # uwsgi_param X-Forwarded-For $proxy_add_x_forwarded_for; + # uwsgi_param X-Forwarded-Proto $http_x_forwarded_proto; + } } diff --git a/contrib/uwsgi.ini b/contrib/uwsgi.ini new file mode 100644 index 000000000..a8bedc1d7 --- /dev/null +++ b/contrib/uwsgi.ini @@ -0,0 +1,34 @@ +[uwsgi] +; bind to the specified UNIX/TCP socket and port (usually localhost) +socket = 127.0.0.1:8001 + +; fail to start if any parameter in the configuration file isn’t explicitly understood by uWSGI. +strict = true + +; re-spawn and pre-fork workers +master = true + +; clear environment on exit +vacuum = true + +; make SIGTERM stop the app (instead of reload) +die-on-term = true + +; exit if no app can be loaded +need-app = true + +; do not use multiple interpreters +single-interpreter = true + +; change to the project directory +chdir = netbox + +; specify the WSGI module to load +module = netbox.wsgi + +; workaround to make uWSGI reloads work with pyuwsgi (not to be used if using uwsgi package instead) +binary-path = venv/bin/python + +; only log internal messages and errors (reverse proxy already logs the requests) +disable-logging = true +log-5xx = true diff --git a/docs/_theme/main.html b/docs/_theme/main.html index 3ff44b9cb..99907bf42 100644 --- a/docs/_theme/main.html +++ b/docs/_theme/main.html @@ -2,8 +2,8 @@ {% block site_meta %} {{ super() }} - {# Disable search indexing unless we're building for ReadTheDocs (see #10496) #} - {% if page.canonical_url != 'https://docs.netbox.dev/' %} + {# Disable search indexing unless we're building for public consumption #} + {% if not config.extra.build_public %} {% endif %} {% endblock %} diff --git a/docs/administration/authentication/microsoft-azure-ad.md b/docs/administration/authentication/microsoft-azure-ad.md index a5e24b0c9..17b130818 100644 --- a/docs/administration/authentication/microsoft-azure-ad.md +++ b/docs/administration/authentication/microsoft-azure-ad.md @@ -73,7 +73,7 @@ You should be redirected to Microsoft's authentication portal. Enter the usernam If successful, you will be redirected back to the NetBox UI, and will be logged in as the AD user. You can verify this by navigating to your profile (using the button at top right). -This user account has been replicated locally to NetBox, and can now be assigned groups and permissions by navigating to Admin > Permissions. +This user account has been replicated locally to NetBox, and can now be assigned groups and permissions. ## Troubleshooting diff --git a/docs/administration/authentication/okta.md b/docs/administration/authentication/okta.md index 67c0ea41b..2cab186ee 100644 --- a/docs/administration/authentication/okta.md +++ b/docs/administration/authentication/okta.md @@ -67,4 +67,4 @@ You should be redirected to Okta's authentication portal. Enter the username/ema If successful, you will be redirected back to the NetBox UI, and will be logged in as the Okta user. You can verify this by navigating to your profile (using the button at top right). -This user account has been replicated locally to NetBox, and can now be assigned groups and permissions by navigating to Admin > Permissions. +This user account has been replicated locally to NetBox, and can now be assigned groups and permissions. diff --git a/docs/administration/authentication/overview.md b/docs/administration/authentication/overview.md index f81a50c0b..a6c3a3159 100644 --- a/docs/administration/authentication/overview.md +++ b/docs/administration/authentication/overview.md @@ -4,7 +4,7 @@ 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. -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 users and/or groups under Admin > Permissions. +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. ## Remote Authentication @@ -26,7 +26,10 @@ REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend' Another option for remote authentication in NetBox is to enable HTTP header-based user assignment. The front end HTTP server (e.g. nginx or Apache) performs client authentication as a process external to NetBox, and passes information about the authenticated user via HTTP headers. By default, the user is assigned via the `REMOTE_USER` header, but this can be customized via the `REMOTE_AUTH_HEADER` configuration parameter. -Optionally, user profile information can be supplied by `REMOTE_USER_FIRST_NAME`, `REMOTE_USER_LAST_NAME` and `REMOTE_USER_EMAIL` headers. These are saved to the users profile during the authentication process. These headers can be customized like the `REMOTE_USER` header. +Optionally, user profile information can be supplied by `REMOTE_USER_FIRST_NAME`, `REMOTE_USER_LAST_NAME` and `REMOTE_USER_EMAIL` headers. These are saved to the user's profile during the authentication process. These headers can be customized like the `REMOTE_USER` header. + +!!! warning Verify Header Compatibility + Some WSGI servers may drop headers which contain unsupported characters. For instance, gunicorn v22.0 and later silently drops HTTP headers containing underscores. This behavior can be disabled by changing gunicorn's [`header_map`](https://docs.gunicorn.org/en/stable/settings.html#header-map) setting to `dangerous`. ### Single Sign-On (SSO) diff --git a/docs/administration/permissions.md b/docs/administration/permissions.md index 95f0f0c05..d16d599d9 100644 --- a/docs/administration/permissions.md +++ b/docs/administration/permissions.md @@ -70,8 +70,6 @@ The `$user` token can be used only as a constraint value, or as an item within a ### Default Permissions -!!! info "This feature was introduced in NetBox v3.6." - While permissions are typically assigned to specific groups and/or users, it is also possible to define a set of default permissions that are applied to _all_ authenticated users. This is done using the [`DEFAULT_PERMISSIONS`](../configuration/security.md#default_permissions) configuration parameter. Note that statically configuring permissions for specific users or groups is **not** supported. ### Example Constraint Definitions diff --git a/docs/configuration/date-time.md b/docs/configuration/date-time.md deleted file mode 100644 index a23053e08..000000000 --- a/docs/configuration/date-time.md +++ /dev/null @@ -1,23 +0,0 @@ -# Date & Time Parameters - -## TIME_ZONE - -Default: UTC - -The time zone NetBox will use when dealing with dates and times. It is recommended to use UTC time unless you have a specific need to use a local time zone. Please see the [list of available time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). - -## Date and Time Formatting - -You may define custom formatting for date and times. For detailed instructions on writing format strings, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#date). Default formats are listed below. - -!!! note - These system defaults will be overridden by a user's selected language/locale when [localization](./system.md#enable_localization) is enabled. - -```python -DATE_FORMAT = 'N j, Y' # June 26, 2016 -SHORT_DATE_FORMAT = 'Y-m-d' # 2016-06-26 -TIME_FORMAT = 'g:i a' # 1:23 p.m. -SHORT_TIME_FORMAT = 'H:i:s' # 13:23:00 -DATETIME_FORMAT = 'N j, Y g:i a' # June 26, 2016 1:23 p.m. -SHORT_DATETIME_FORMAT = 'Y-m-d H:i' # 2016-06-26 13:23 -``` diff --git a/docs/configuration/miscellaneous.md b/docs/configuration/miscellaneous.md index 4d4ca189e..1f0a2781b 100644 --- a/docs/configuration/miscellaneous.md +++ b/docs/configuration/miscellaneous.md @@ -33,9 +33,6 @@ This defines custom content to be displayed on the login page above the login fo !!! tip "Dynamic Configuration Parameter" -!!! note - This parameter was added in NetBox v3.5. - This adds a banner to the top of every page when maintenance mode is enabled. HTML is allowed. --- @@ -99,6 +96,14 @@ The maximum size (in bytes) of an incoming HTTP request (i.e. `GET` or `POST` da --- +## DJANGO_ADMIN_ENABLED + +Default: False + +Setting this to True installs the `django.contrib.admin` app and enables the [Django admin UI](https://docs.djangoproject.com/en/5.0/ref/contrib/admin/). This may be necessary to support older plugins which do not integrate with the native NetBox interface. + +--- + ## ENFORCE_GLOBAL_UNIQUE !!! tip "Dynamic Configuration Parameter" @@ -107,9 +112,6 @@ Default: True By default, NetBox will prevent the creation of duplicate prefixes and IP addresses in the global table (that is, those which are not assigned to any VRF). This validation can be disabled by setting `ENFORCE_GLOBAL_UNIQUE` to False. -!!! info "Changed in v3.7" - The default value for this parameter was changed from False to True in NetBox v3.7. - --- ## FILE_UPLOAD_MAX_MEMORY_SIZE @@ -134,9 +136,6 @@ Setting this to False will disable the GraphQL API. !!! tip "Dynamic Configuration Parameter" -!!! note - This parameter was renamed from `JOBRESULT_RETENTION` in NetBox v3.5. - Default: 90 The number of days to retain job results (scripts and reports). Set this to `0` to retain job results in the database indefinitely. @@ -231,9 +230,6 @@ The maximum execution time of a background task (such as running a custom script ## RQ_RETRY_INTERVAL -!!! note - This parameter was added in NetBox v3.5. - Default: `60` This parameter controls how frequently a failed job is retried, up to the maximum number of times specified by `RQ_RETRY_MAX`. This must be either an integer specifying the number of seconds to wait between successive attempts, or a list of such values. For example, `[60, 300, 3600]` will retry the task after 1 minute, 5 minutes, and 1 hour. @@ -242,9 +238,6 @@ This parameter controls how frequently a failed job is retried, up to the maximu ## RQ_RETRY_MAX -!!! note - This parameter was added in NetBox v3.5. - Default: `0` (retries disabled) The maximum number of times a background task will be retried before being marked as failed. diff --git a/docs/configuration/remote-authentication.md b/docs/configuration/remote-authentication.md index e7fe56a09..5f28d987f 100644 --- a/docs/configuration/remote-authentication.md +++ b/docs/configuration/remote-authentication.md @@ -85,6 +85,9 @@ Default: `'HTTP_REMOTE_USER'` When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. For example, to use the request header `X-Remote-User` it needs to be set to `HTTP_X_REMOTE_USER`. (Requires `REMOTE_AUTH_ENABLED`.) +!!! warning Verify Header Compatibility + Some WSGI servers may drop headers which contain unsupported characters. For instance, gunicorn v22.0 and later silently drops HTTP headers containing underscores. This behavior can be disabled by changing gunicorn's [`header_map`](https://docs.gunicorn.org/en/stable/settings.html#header-map) setting to `dangerous`. + --- ## REMOTE_AUTH_USER_EMAIL diff --git a/docs/configuration/required-parameters.md b/docs/configuration/required-parameters.md index bda365995..90eb8c0cf 100644 --- a/docs/configuration/required-parameters.md +++ b/docs/configuration/required-parameters.md @@ -94,15 +94,25 @@ REDIS = { } ``` -!!! note - If you are upgrading from a NetBox release older than v2.7.0, please note that the Redis connection configuration - settings have changed. Manual modification to bring the `REDIS` section inline with the above specification is - necessary - !!! warning It is highly recommended to keep the task and cache databases separate. Using the same database number on the same Redis instance for both may result in queued background tasks being lost during cache flushing events. +### UNIX Socket Support + +Redis may alternatively be configured by specifying a complete URL instead of individual components. This approach supports the use of a UNIX socket connection. For example: + +```python +REDIS = { + 'tasks': { + 'URL': 'unix:///run/redis-netbox/redis.sock?db=0' + }, + 'caching': { + 'URL': 'unix:///run/redis-netbox/redis.sock?db=1' + }, +} +``` + ### Using Redis Sentinel If you are using [Redis Sentinel](https://redis.io/topics/sentinel) for high-availability purposes, there is minimal diff --git a/docs/configuration/security.md b/docs/configuration/security.md index 2ae92285f..15702f649 100644 --- a/docs/configuration/security.md +++ b/docs/configuration/security.md @@ -92,8 +92,6 @@ CSRF_TRUSTED_ORIGINS = ( ## DEFAULT_PERMISSIONS -!!! info "This parameter was introduced in NetBox v3.6." - Default: ```python @@ -161,9 +159,12 @@ Note that enabling this setting causes NetBox to update a user's session in the ## LOGIN_REQUIRED -Default: False +Default: True -Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users are permitted to access most data in NetBox but not make any changes. +When enabled, only authenticated users are permitted to access any part of NetBox. Disabling this will allow unauthenticated users to access most areas of NetBox (but not make any changes). + +!!! info "Changed in NetBox v4.0.2" + Prior to NetBox v4.0.2, this setting was disabled by default. --- @@ -183,6 +184,30 @@ The view name or URL to which a user is redirected after logging out. --- +## SECURE_HSTS_INCLUDE_SUBDOMAINS + +Default: False + +If true, the `includeSubDomains` directive will be included in the HTTP Strict Transport Security (HSTS) header. This directive instructs the browser to apply the HSTS policy to all subdomains of the current domain. + +--- + +## SECURE_HSTS_PRELOAD + +Default: False + +If true, the `preload` directive will be included in the HTTP Strict Transport Security (HSTS) header. This directive instructs the browser to preload the site in HTTPS. Browsers that use the HSTS preload list will force the site to be accessed via HTTPS even if the user types HTTP in the address bar. + +--- + +## SECURE_HSTS_SECONDS + +Default: 0 + +If set to a non-zero integer value, the SecurityMiddleware sets the HTTP Strict Transport Security (HSTS) header on all responses that do not already have it. This will instruct the browser that the website must be accessed via HTTPS, blocking any HTTP request. + +--- + ## SECURE_SSL_REDIRECT Default: False diff --git a/docs/configuration/system.md b/docs/configuration/system.md index 806839778..a1e0ebb17 100644 --- a/docs/configuration/system.md +++ b/docs/configuration/system.md @@ -16,10 +16,7 @@ BASE_PATH = 'netbox/' Default: `en-us` (US English) -Defines the default preferred language/locale for requests that do not specify one. This is used to alter e.g. the display of dates and numbers to fit the user's locale. See [this list](http://www.i18nguy.com/unicode/language-identifiers.html) of standard language codes. (This parameter maps to Django's [`LANGUAGE_CODE`](https://docs.djangoproject.com/en/stable/ref/settings/#language-code) internal setting.) - -!!! note - Altering this parameter will *not* change the language used in NetBox. We hope to provide translation support in a future NetBox release. +Defines the default preferred language/locale for requests that do not specify one. (This parameter maps to Django's [`LANGUAGE_CODE`](https://docs.djangoproject.com/en/stable/ref/settings/#language-code) internal setting.) --- @@ -65,14 +62,6 @@ Email is sent from NetBox only for critical events or if configured for [logging --- -## ENABLE_LOCALIZATION - -Default: False - -Determines if localization features are enabled or not. This should only be enabled for development or testing purposes as netbox is not yet fully localized. Turning this on will localize numeric and date formats (overriding any configured [system defaults](./date-time.md#date-and-time-formatting)) based on the browser locale as well as translate certain strings from third party modules. - ---- - ## HTTP_PROXIES Default: None @@ -203,3 +192,17 @@ A dictionary of configuration parameters for the storage backend configured as ` If `STORAGE_BACKEND` is not defined, this setting will be ignored. --- + +## TIME_ZONE + +Default: UTC + +The time zone NetBox will use when dealing with dates and times. It is recommended to use UTC time unless you have a specific need to use a local time zone. Please see the [list of available time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). + +--- + +## TRANSLATION_ENABLED + +Default: True + +Enables language translation for the user interface. (This parameter maps to Django's [USE_I18N](https://docs.djangoproject.com/en/stable/ref/settings/#std-setting-USE_I18N) setting.) diff --git a/docs/customization/custom-fields.md b/docs/customization/custom-fields.md index e9ff7bd9f..1f9a4a8bf 100644 --- a/docs/customization/custom-fields.md +++ b/docs/customization/custom-fields.md @@ -42,8 +42,6 @@ This parameter has no effect on the API representation of custom field data. ### Visibility & Editing -!!! info "This feature was improved in NetBox v3.7." - When creating a custom field, users can control the conditions under which it may be displayed and edited within the NetBox user interface. The following choices are available for controlling the display of a custom field on an object: * **Always** (default): The custom field is included when viewing an object. diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index c68bc21f1..21ae20f05 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -5,8 +5,17 @@ Custom scripting was introduced to provide a way for users to execute custom log * Automatically populate new devices and cables in preparation for a new site deployment * Create a range of new reserved prefixes or IP addresses * Fetch data from an external source and import it to NetBox +* Update objects with invalid or incomplete data -Custom scripts are Python code and exist outside of the official NetBox code base, so they can be updated and changed without interfering with the core NetBox installation. And because they're completely custom, there is no inherent limitation on what a script can accomplish. +They can also be used as a mechanism for validating the integrity of data within NetBox. Script authors can define test to check object against specific rules and conditions. For example, you can write script to check that: + +* All top-of-rack switches have a console connection +* Every router has a loopback interface with an IP address assigned +* Each interface description conforms to a standard format +* Every site has a minimum set of VLANs defined +* All IP addresses have a parent prefix + +Custom scripts are Python code which exists outside the NetBox code base, so they can be updated and changed without interfering with the core NetBox installation. And because they're completely custom, there is no inherent limitation on what a script can accomplish. ## Writing Custom Scripts @@ -135,13 +144,73 @@ These two methods will load data in YAML or JSON format, respectively, from file The Script object provides a set of convenient functions for recording messages at different severity levels: -* `log_debug` -* `log_success` -* `log_info` -* `log_warning` -* `log_failure` +* `log_debug(message, object=None)` +* `log_success(message, object=None)` +* `log_info(message, object=None)` +* `log_warning(message, object=None)` +* `log_failure(message, object=None)` -Log messages are returned to the user upon execution of the script. Markdown rendering is supported for log messages. +Log messages are returned to the user upon execution of the script. Markdown rendering is supported for log messages. A message may optionally be associated with a particular object by passing it as the second argument to the logging method. + +## Test Methods + +A script can define one or more test methods to report on certain conditions. All test methods must have a name beginning with `test_` and accept no arguments beyond `self`. + +These methods are detected and run automatically when the script is executed, unless its `run()` method has been overridden. (When overriding `run()`, `run_tests()` can be called to run all test methods present in the script.) + +!!! info + This functionality was ported from [legacy reports](./reports.md) in NetBox v4.0. + +### Example + +``` +from dcim.choices import DeviceStatusChoices +from dcim.models import ConsolePort, Device, PowerPort +from extras.scripts import Script + + +class DeviceConnectionsReport(Script): + description = "Validate the minimum physical connections for each device" + + def test_console_connection(self): + + # Check that every console port for every active device has a connection defined. + active = DeviceStatusChoices.STATUS_ACTIVE + for console_port in ConsolePort.objects.prefetch_related('device').filter(device__status=active): + if not console_port.connected_endpoints: + self.log_failure( + f"No console connection defined for {console_port.name}", + console_port.device, + ) + elif not console_port.connection_status: + self.log_warning( + f"Console connection for {console_port.name} marked as planned", + console_port.device, + ) + else: + self.log_success("Passed", console_port.device) + + def test_power_connections(self): + + # Check that every active device has at least two connected power supplies. + for device in Device.objects.filter(status=DeviceStatusChoices.STATUS_ACTIVE): + connected_ports = 0 + for power_port in PowerPort.objects.filter(device=device): + if power_port.connected_endpoints: + connected_ports += 1 + if not power_port.path.is_active: + self.log_warning( + f"Power connection for {power_port.name} marked as planned", + device, + ) + if connected_ports < 2: + self.log_failure( + f"{connected_ports} connected power supplies found (2 needed)", + device, + ) + else: + self.log_success("Passed", device) +``` ## Change Logging @@ -235,6 +304,7 @@ A particular object within NetBox. Each ObjectVar must specify a particular mode * `model` - The model class * `query_params` - A dictionary of query parameters to use when retrieving available options (optional) +* `context` - A custom dictionary mapping template context variables to fields, used when rendering `

+

Title text

+ +
+``` + +### Check column offsets + +When using [offset columns](https://getbootstrap.com/docs/5.3/layout/columns/#offsetting-columns) (e.g. `class="col-offset-3"`), be sure to also set the column width (e.g. `class="col-9 col-offset-3"`) to avoid horizontal scrolling. + +### Tables inside cards + +Tables inside cards should be embedded directly, not nested inside a `card-body` element. + +```html title="Old" +
+
+ + ... +
+
+
+``` + +```html title="New" +
+ + ... +
+
+``` + +### Remove `btn-sm` class from buttons + +The `btn-sm` (small) class is no longer typically needed on general-purpose buttons. + +```html title="Old" +Text +``` + +```html title="New" +Text +``` + +### Update `bg-$color` classes + +Foreground (text) color is no longer automatically adjusted by `bg-$color` classes. To ensure sufficient contrast with the background color, use the [`text-bg-$color`](https://getbootstrap.com/docs/5.3/helpers/color-background/) form of the class instead, or set the text color separately with `text-$color`. + +```html title="Old" +Text +``` + +```html title="New" +Text +``` + +### Obsolete custom CSS classes + +The following custom CSS classes have been removed: + +* `object-subtitle` (use `text-secondary` instead) + +## REST API + +### Extend serializer for brief mode + +NetBox now uses a single API serializer for both normal and "brief" modes (i.e. `GET /api/dcim/sites/?brief=true`); nested serializer classes are no longer required. Two changes to API serializers are necessary to support brief mode: + +1. Define `brief_fields` under its `Meta` class. These are the fields which will be included when brief mode is used. +2. For any nested objects, switch to using the primary serializer and pass `nested=True`. + +Any nested serializers which are no longer needed can be removed. + +```python title="Old" +class SiteSerializer(NetBoxModelSerializer): + region = NestedRegionSerializer(required=False, allow_null=True) + + class Meta: + model = Site + fields = ('id', 'url', 'display', 'name', 'slug', 'status', 'region', 'time_zone', ...) +``` + +```python title="New" +class SiteSerializer(NetBoxModelSerializer): + region = RegionSerializer(nested=True, required=False, allow_null=True) + + class Meta: + model = Site + fields = ('id', 'url', 'display', 'name', 'slug', 'status', 'region', 'time_zone', ...) + brief_fields = ('id', 'url', 'display', 'name', 'description', 'slug') +``` + +### Include description fields in brief mode + +NetBox now includes the `description` field in "brief" mode for all models which have one. This is not required for plugins, but you may opt to do the same for consistency. + +## GraphQL + +NetBox has replaced [Graphene-Django](https://github.com/graphql-python/graphene-django) with [Strawberry](https://strawberry.rocks/) which requires any GraphQL code to be updated. + +### Change schema.py + +Strawberry uses [Python typing](https://docs.python.org/3/library/typing.html) and generally only requires a small refactoring of the schema definition to update: + +```python title="Old" +import graphene +from netbox.graphql.fields import ObjectField, ObjectListField +from utilities.graphql_optimizer import gql_query_optimizer + +class CircuitsQuery(graphene.ObjectType): + circuit = ObjectField(CircuitType) + circuit_list = ObjectListField(CircuitType) + + def resolve_circuit_list(root, info, **kwargs): + return gql_query_optimizer(models.Circuit.objects.all(), info) +``` + +```python title="New" +import strawberry +import strawberry_django + +@strawberry.type +class CircuitsQuery: + @strawberry.field + def circuit(self, id: int) -> CircuitType: + return models.Circuit.objects.get(pk=id) + circuit_list: list[CircuitType] = strawberry_django.field() +``` + +### Change types.py + +Type conversion is also fairly straight-forward, but Strawberry requires FK and M2M references to be explicitly defined to pick up the right typing. + +1. The `class Meta` options need to be moved up to the Strawberry decorator +2. Add `@strawberry_django.field` definitions for any FK and M2M references in the model + +```python title="Old" +import graphene + +class CircuitType(NetBoxObjectType, ContactsMixin): + class Meta: + model = models.Circuit + fields = '__all__' + filterset_class = filtersets.CircuitFilterSet +``` + +```python title="New" +from typing import Annotated + +import strawberry +import strawberry_django + +@strawberry_django.type( + models.CircuitType, + fields='__all__', + filters=CircuitTypeFilter +) +class CircuitTypeType(OrganizationalObjectType): + color: str + + @strawberry_django.field + def circuits(self) -> list[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]]: + return self.circuits.all() +``` + +### Change filters.py + +Strawberry currently doesn't directly support django-filter, so an explicit filters.py file will need to be created. NetBox includes a new `autotype_decorator` used to automatically wrap FilterSets to reduce the required code to a minimum. + +```python title="New" +import strawberry +import strawberry_django +from circuits import filtersets, models + +from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin + +__all__ = ( + 'CircuitFilter', +) + + +@strawberry_django.filter(models.Circuit, lookups=True) +@autotype_decorator(filtersets.CircuitFilterSet) +class CircuitFilter(BaseFilterMixin): + pass + +``` diff --git a/docs/plugins/development/navigation.md b/docs/plugins/development/navigation.md index dc895b2ab..90b523473 100644 --- a/docs/plugins/development/navigation.md +++ b/docs/plugins/development/navigation.md @@ -49,8 +49,8 @@ menu_items = (item1, item2, item3) Each menu item represents a link and (optionally) a set of buttons comprising one entry in NetBox's navigation menu. Menu items are defined as PluginMenuItem instances. An example is shown below. ```python title="navigation.py" +from netbox.choices import ButtonColorChoices from netbox.plugins import PluginMenuButton, PluginMenuItem -from utilities.choices import ButtonColorChoices item1 = PluginMenuItem( link='plugins:myplugin:myview', @@ -72,8 +72,6 @@ A `PluginMenuItem` has the following attributes: | `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 | -!!! info "The `staff_only` attribute was introduced in NetBox v3.6.1." - ## Menu Buttons Each menu item can include a set of buttons. These can be handy for providing shortcuts related to the menu item. For instance, most items in NetBox's navigation menu include buttons to create and import new objects. diff --git a/docs/plugins/development/tables.md b/docs/plugins/development/tables.md index 9d57a9603..6fe8357b3 100644 --- a/docs/plugins/development/tables.md +++ b/docs/plugins/development/tables.md @@ -90,8 +90,6 @@ The table column classes listed below are supported for use in plugins. These cl ## Extending Core Tables -!!! info "This feature was introduced in NetBox v3.7." - Plugins can register their own custom columns on core tables using the `register_table_column()` utility function. This allows a plugin to attach additional information, such as relationships to its own models, to built-in object lists. ```python diff --git a/docs/plugins/development/views.md b/docs/plugins/development/views.md index 1730b0ebd..3c13a6fcb 100644 --- a/docs/plugins/development/views.md +++ b/docs/plugins/development/views.md @@ -157,7 +157,7 @@ These views are provided to enable or enhance certain NetBox model features, suc ### Additional Tabs -Plugins can "attach" a custom view to a core NetBox model by registering it with `register_model_view()`. To include a tab for this view within the NetBox UI, declare a TabView instance named `tab`: +Plugins can "attach" a custom view to a core NetBox model by registering it with `register_model_view()`. To include a tab for this view within the NetBox UI, declare a TabView instance named `tab`, and add it to the template context dict: ```python from dcim.models import Site @@ -173,6 +173,16 @@ class MyView(generic.ObjectView): badge=lambda obj: Stuff.objects.filter(site=obj).count(), permission='myplugin.view_stuff' ) + + def get(self, request, pk): + ... + return render( + request, + "myplugin/mytabview.html", + context={ + "tab": self.tab, + }, + ) ``` ::: utilities.views.register_model_view diff --git a/docs/plugins/index.md b/docs/plugins/index.md index 0658ed402..0f502c5d8 100644 --- a/docs/plugins/index.md +++ b/docs/plugins/index.md @@ -2,6 +2,8 @@ Plugins are packaged [Django](https://docs.djangoproject.com/) apps that can be installed alongside NetBox to provide custom functionality not present in the core application. Plugins can introduce their own models and views, but cannot interfere with existing components. A NetBox user may opt to install plugins provided by the community or build his or her own. +Please see the documented instructions for [installing a plugin](./installation.md) to get started. + ## Capabilities The NetBox plugin architecture allows for the following: @@ -23,122 +25,3 @@ Either by policy or by technical limitation, the interaction of plugins with Net * **Override core templates.** Plugins can inject additional content where supported, but may not manipulate or remove core content. * **Modify core settings.** A configuration registry is provided for plugins, however they cannot alter or delete the core configuration. * **Disable core components.** Plugins are not permitted to disable or hide core NetBox components. - -## Installing Plugins - -The instructions below detail the process for installing and enabling a NetBox plugin. - -### Install Package - -Download and install the plugin package per its installation instructions. Plugins published via PyPI are typically installed using pip. Be sure to install the plugin within NetBox's virtual environment. - -```no-highlight -$ source /opt/netbox/venv/bin/activate -(venv) $ pip install -``` - -Alternatively, you may wish to install the plugin manually by running `python setup.py install`. If you are developing a plugin and want to install it only temporarily, run `python setup.py develop` instead. - -### Enable the Plugin - -In `configuration.py`, add the plugin's name to the `PLUGINS` list: - -```python -PLUGINS = [ - 'plugin_name', -] -``` - -### Configure Plugin - -If the plugin requires any configuration, define it in `configuration.py` under the `PLUGINS_CONFIG` parameter. The available configuration parameters should be detailed in the plugin's README file. - -```no-highlight -PLUGINS_CONFIG = { - 'plugin_name': { - 'foo': 'bar', - 'buzz': 'bazz' - } -} -``` - -### Run Database Migrations - -If the plugin introduces new database models, run the provided schema migrations: - -```no-highlight -(venv) $ cd /opt/netbox/netbox/ -(venv) $ python3 manage.py migrate -``` - -### Collect Static Files - -Plugins may package static files to be served directly by the HTTP front end. Ensure that these are copied to the static root directory with the `collectstatic` management command: - -```no-highlight -(venv) $ cd /opt/netbox/netbox/ -(venv) $ python3 manage.py collectstatic -``` - -### Restart WSGI Service - -Restart the WSGI service to load the new plugin: - -```no-highlight -# sudo systemctl restart netbox -``` - -## Removing Plugins - -Follow these steps to completely remove a plugin. - -### Update Configuration - -Remove the plugin from the `PLUGINS` list in `configuration.py`. Also remove any relevant configuration parameters from `PLUGINS_CONFIG`. - -### Remove the Python Package - -Use `pip` to remove the installed plugin: - -```no-highlight -$ source /opt/netbox/venv/bin/activate -(venv) $ pip uninstall -``` - -### Restart WSGI Service - -Restart the WSGI service: - -```no-highlight -# sudo systemctl restart netbox -``` - -### Drop Database Tables - -!!! note - This step is necessary only for plugin which have created one or more database tables (generally through the introduction of new models). Check your plugin's documentation if unsure. - -Enter the PostgreSQL database shell to determine if the plugin has created any SQL tables. Substitute `pluginname` in the example below for the name of the plugin being removed. (You can also run the `\dt` command without a pattern to list _all_ tables.) - -```no-highlight -netbox=> \dt pluginname_* - List of relations - List of relations - Schema | Name | Type | Owner ---------+----------------+-------+-------- - public | pluginname_foo | table | netbox - public | pluginname_bar | table | netbox -(2 rows) -``` - -!!! warning - Exercise extreme caution when removing tables. Users are strongly encouraged to perform a backup of their database immediately before taking these actions. - -Drop each of the listed tables to remove it from the database: - -```no-highlight -netbox=> DROP TABLE pluginname_foo; -DROP TABLE -netbox=> DROP TABLE pluginname_bar; -DROP TABLE -``` diff --git a/docs/plugins/installation.md b/docs/plugins/installation.md new file mode 100644 index 000000000..ffea5d42d --- /dev/null +++ b/docs/plugins/installation.md @@ -0,0 +1,68 @@ +# Installing a Plugin + +!!! warning + The instructions below detail the general process for installing and configuring a NetBox plugin. However, each plugin is different and may require additional tasks or modifications to the steps below. Always consult the documentation for a specific plugin **before** attempting to install it. + +## Install the Python Package + +Download and install the plugin's Python package per its installation instructions. Plugins published via PyPI are typically installed using the [`pip`](https://packaging.python.org/en/latest/tutorials/installing-packages/) command line utility. Be sure to install the plugin within NetBox's virtual environment. + +```no-highlight +$ source /opt/netbox/venv/bin/activate +(venv) $ pip install +``` + +Alternatively, you may wish to install the plugin manually by running `python setup.py install`. If you are developing a plugin and want to install it only temporarily, run `python setup.py develop` instead. + +## Enable the Plugin + +In `configuration.py`, add the plugin's name to the `PLUGINS` list: + +```python +PLUGINS = [ + # ... + 'plugin_name', +] +``` + +## Configure the Plugin + +If the plugin requires any configuration, define it in `configuration.py` under the `PLUGINS_CONFIG` parameter. The available configuration parameters should be detailed in the plugin's `README` file or other documentation. + +```no-highlight +PLUGINS_CONFIG = { + 'plugin_name': { + 'foo': 'bar', + 'buzz': 'bazz' + } +} +``` + +## Run Database Migrations + +If the plugin introduces new database models, run the provided schema migrations: + +```no-highlight +(venv) $ cd /opt/netbox/netbox/ +(venv) $ python3 manage.py migrate +``` + +!!! tip + It's okay to run the `migrate` management command even if the plugin does not include any migration files. + +## Collect Static Files + +Plugins may package static resources like images or scripts to be served directly by the HTTP front end. Ensure that these are copied to the static root directory with the `collectstatic` management command: + +```no-highlight +(venv) $ cd /opt/netbox/netbox/ +(venv) $ python3 manage.py collectstatic +``` + +### Restart WSGI Service + +Finally, restart the WSGI service and RQ workers to load the new plugin: + +```no-highlight +# sudo systemctl restart netbox netbox-rq +``` diff --git a/docs/plugins/removal.md b/docs/plugins/removal.md new file mode 100644 index 000000000..f5e81bdc0 --- /dev/null +++ b/docs/plugins/removal.md @@ -0,0 +1,72 @@ +# Removing a Plugin + +!!! warning + The instructions below detail the general process for removing a NetBox plugin. However, each plugin is different and may require additional tasks or modifications to the steps below. Always consult the documentation for a specific plugin **before** attempting to remove it. + +## Disable the Plugin + +Disable the plugin by removing it from the `PLUGINS` list in `configuration.py`. + +## Remove its Configuration + +Delete the plugin's entry (if any) in the `PLUGINS_CONFIG` dictionary in `configuration.py`. + +!!! tip + If there's a chance you may reinstall the plugin, consider commenting out any configuration parameters instead of deleting them. + +## Re-index Search Entries + +Run the `reindex` management command to reindex the global search engine. This will remove any stale entries pertaining to objects provided by the plugin. + +```no-highlight +$ cd /opt/netbox/netbox/ +$ source /opt/netbox/venv/bin/activate +(venv) $ python3 manage.py reindex +``` + +## Uninstall its Python Package + +Use `pip` to remove the installed plugin: + +```no-highlight +$ source /opt/netbox/venv/bin/activate +(venv) $ pip uninstall +``` + +## Restart WSGI Service + +Restart the WSGI service: + +```no-highlight +# sudo systemctl restart netbox +``` + +## Drop Database Tables + +!!! note + This step is necessary only for plugins which have created one or more database tables (generally through the introduction of new models). Check your plugin's documentation if unsure. + +Enter the PostgreSQL database shell (`manage.py dbshell`) to determine if the plugin has created any SQL tables. Substitute `pluginname` in the example below for the name of the plugin being removed. (You can also run the `\dt` command without a pattern to list _all_ tables.) + +```no-highlight +netbox=> \dt pluginname_* + List of relations + List of relations + Schema | Name | Type | Owner +--------+----------------+-------+-------- + public | pluginname_foo | table | netbox + public | pluginname_bar | table | netbox +(2 rows) +``` + +!!! warning + Exercise extreme caution when removing tables. Users are strongly encouraged to perform a backup of their database immediately before taking these actions. + +Drop each of the listed tables to remove it from the database: + +```no-highlight +netbox=> DROP TABLE pluginname_foo; +DROP TABLE +netbox=> DROP TABLE pluginname_bar; +DROP TABLE +``` diff --git a/docs/reference/markdown.md b/docs/reference/markdown.md index 0759fa2ec..cff3f0943 100644 --- a/docs/reference/markdown.md +++ b/docs/reference/markdown.md @@ -1,353 +1,254 @@ ---- -hide: - - toc ---- - # Markdown -NetBox supports markdown rendering for certain text fields. +NetBox supports Markdown rendering for certain text fields. Some common examples are provided below. For a complete Markdown reference, please see [Markdownguide.org](https://www.markdownguide.org/basic-syntax/). -## Syntax - -##### Table of Contents -[Headers](#headers) -[Emphasis](#emphasis) -[Lists](#lists) -[Links](#links) -[Images](#images) -[Code Blocks](#code) -[Tables](#tables) -[Blockquotes](#blockquotes) -[Inline HTML](#html) -[Horizontal Rule](#hr) -[Line Breaks](#lines) - - - -## Headers +## Headings ```no-highlight -# H1 -## H2 -### H3 -#### H4 -##### H5 -###### H6 +# Heading 1 +## Heading 2 +### Heading 3 +#### Heading 4 +##### Heading 5 +###### Heading 6 +``` + +

Heading 1

+

Heading 2

+

Heading 3

+

Heading 4

+
Heading 5
+
Heading 6
Alternatively, for H1 and H2, an underline-ish style: -Alt-H1 -====== +```no-highlight +Heading 1 +========= -Alt-H2 ------- +Heading 2 +--------- ``` -# H1 -## H2 -### H3 -#### H4 -##### H5 -###### H6 +

Heading 1

+

Heading 2

- - -## Emphasis +## Text ```no-highlight -Emphasis, aka italics, with *asterisks* or _underscores_. - -Strong emphasis, aka bold, with **asterisks** or __underscores__. - -Combined emphasis with **asterisks and _underscores_**. - -Strikethrough uses two tildes. ~~Scratch this.~~ +Italicize text with *asterisks* or _underscores_. ``` -Emphasis, aka italics, with *asterisks* or _underscores_. - -Strong emphasis, aka bold, with **asterisks** or __underscores__. - -Combined emphasis with **asterisks and _underscores_**. - -Strikethrough uses two tildes. ~~Scratch this.~~ - - - - -## Lists - -(In this example, leading and trailing spaces are shown with with dots: ⋅) +Italicize text with *asterisks* or _underscores_. ```no-highlight -1. First ordered list item -2. Another item -⋅⋅* Unordered sub-list. -1. Actual numbers don't matter, just that it's a number -⋅⋅1. Ordered sub-list -4. And another item. - -⋅⋅⋅You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown). - -⋅⋅⋅To have a line break without a paragraph, you will need to use two trailing spaces.⋅⋅ -⋅⋅⋅Note that this line is separate, but within the same paragraph.⋅⋅ -⋅⋅⋅(This is contrary to the typical GFM line break behaviour, where trailing spaces are not required.) - -* Unordered list can use asterisks -- Or minuses -+ Or pluses +Bold text with two **asterisks** or __underscores__. ``` -1. First ordered list item -2. Another item - * Unordered sub-list. -1. Actual numbers don't matter, just that it's a number - 1. Ordered sub-list -4. And another item. - - You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown). - - To have a line break without a paragraph, you will need to use two trailing spaces. - Note that this line is separate, but within the same paragraph. - (This is contrary to the typical GFM line break behaviour, where trailing spaces are not required.) - -* Unordered list can use asterisks -- Or minuses -+ Or pluses - - - -## Links - -There are two ways to create links. +Bold text with two **asterisks** or __underscores__. ```no-highlight -[I'm an inline-style link](https://www.google.com) - -[I'm an inline-style link with title](https://www.google.com "Google's Homepage") - -[I'm a reference-style link][Arbitrary case-insensitive reference text] - -[You can use numbers for reference-style link definitions][1] - -Or leave it empty and use the [link text itself]. - -URLs and URLs in angle brackets will automatically get turned into links. -http://www.example.com or and sometimes -example.com (but not on Github, for example). - -Some text to show that the reference links can follow later. - -[arbitrary case-insensitive reference text]: https://www.mozilla.org -[1]: http://slashdot.org -[link text itself]: http://www.reddit.com +Strike text with two tildes. ~~Deleted text.~~ ``` -[I'm an inline-style link](https://www.google.com) - -[I'm an inline-style link with title](https://www.google.com "Google's Homepage") - -[I'm a reference-style link][Arbitrary case-insensitive reference text] - -[You can use numbers for reference-style link definitions][1] - -Or leave it empty and use the [link text itself]. - -URLs and URLs in angle brackets will automatically get turned into links. -http://www.example.com or and sometimes -example.com (but not on Github, for example). - -Some text to show that the reference links can follow later. - -[arbitrary case-insensitive reference text]: https://www.mozilla.org -[1]: http://slashdot.org -[link text itself]: http://www.reddit.com - - - -## Images - -``` -Here's the NetBox logo (hover to see the title text): - -Inline-style: -![alt text](/media/misc/netbox_logo.png "Logo Title Text 1") - -Reference-style: -![alt text][logo] - -[logo]: /media/misc/netbox_logo.png "Logo Title Text 2" -``` - -Here's the NetBox logo (hover to see the title text): - -Inline-style: -![alt text](../media/misc/netbox_logo.png "Logo Title Text 1") - -Reference-style: -![alt text][logo] - -[logo]: ../media/misc/netbox_logo.png "Logo Title Text 2" - - - -## Code blocks - -``` -Inline `code` has `back-ticks around` it. -``` - -Inline `code` has `back-ticks around` it. - -Blocks of code are fenced by lines with three back-ticks ``` - -```` -``` -var s = "Code block"; -alert(s); -``` -```` - -``` -var s = "Code block"; -alert(s); -``` - - - -## Tables - -```no-highlight -Colons can be used to align columns. - -| Tables | Are | Cool | -| ------------- |:-------------:| -----:| -| col 3 is | right-aligned | $1600 | -| col 2 is | centered | $12 | -| zebra stripes | are neat | $1 | - -There must be at least 3 dashes separating each header cell. -The outer pipes (|) are optional, and you don't need to make the -raw Markdown line up prettily. You can also use inline Markdown. - -Markdown | Less | Pretty ---- | --- | --- -*Still* | `renders` | **nicely** -1 | 2 | 3 -``` - -Colons can be used to align columns. - -| Tables | Are | Cool | -| ------------- |:-------------:| -----:| -| col 3 is | right-aligned | $1600 | -| col 2 is | centered | $12 | -| zebra stripes | are neat | $1 | - -There must be at least 3 dashes separating each header cell. The outer pipes (|) are optional, and you don't need to make the raw Markdown line up prettily. You can also use inline Markdown. - -Markdown | Less | Pretty ---- | --- | --- -*Still* | `renders` | **nicely** -1 | 2 | 3 - - - -## Blockquotes - -```no-highlight -> Blockquotes are very handy in email to emulate reply text. -> This line is part of the same quote. - -Quote break. - -> This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote. -``` - -> Blockquotes are very handy in email to emulate reply text. -> This line is part of the same quote. - -Quote break. - -> This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote. - - - -## Inline HTML - -You can also use raw HTML in your Markdown, and it'll mostly work pretty well. - -```no-highlight -
-
Definition list
-
Is something people use sometimes.
- -
Markdown in HTML
-
Does *not* work **very** well. Use HTML tags.
-
-``` - -
-
Definition list
-
Is something people use sometimes.
- -
Markdown in HTML
-
Does *not* work **very** well. Use HTML tags.
-
- - - -## Horizontal Rule - -``` -Three or more... - ---- - -Hyphens - -*** - -Asterisks - -___ - -Underscores -``` - -Three or more... - ---- - -Hyphens - -*** - -Asterisks - -___ - -Underscores - - +Strike text with two tildes. ~~Deleted text.~~ ## Line Breaks +By default, Markdown will remove line breaks between successive lines of text. For example: -``` -Here's a line for us to start with. - -This line is separated from the one above by two newlines, so it will be a *separate paragraph*. - -This line is also a separate paragraph, but... -This line is only separated by a single newline, so it's a separate line in the *same paragraph*. +```no-highlight +This is one line. +And this is another line. +One more line here. ``` -Here's a line for us to start with. +This is one line. +And this is another line. +One more line here. -This line is separated from the one above by two newlines, so it will be a *separate paragraph*. +To preserve line breaks, append two spaces to each line (represented below with the `⋅` character). -This line is also begins a separate paragraph, but... -This line is only separated by a single newline, so it's a separate line in the *same paragraph*. +```no-highlight +This is one line.⋅⋅ +And this is another line.⋅⋅ +One more line here. +``` -Based on [Markdown-Cheatsheet](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet) by [adam-p](https://github.com/adam-p) licensed under [CC-BY](https://creativecommons.org/licenses/by/3.0/) \ No newline at end of file +This is one line. +And this is another line. +One more line here. + +## Lists + +Use asterisks or hyphens for unordered lists. Indent items by four spaces to start a child list. + +```no-highlight +* Alpha +* Bravo +* Charlie + * Child item 1 + * Child item 2 +* Delta +``` + +* Alpha +* Bravo +* Charlie + * Child item 1 + * Child item 2 +* Delta + +Use digits followed by periods for ordered (numbered) lists. + +```no-highlight +1. Red +2. Green +3. Blue + 1. Light blue + 2. Dark blue +4. Orange +``` + +1. Red +2. Green +3. Blue + 1. Light blue + 2. Dark blue +4. Orange + +## Links + +Text can be rendered as a hyperlink by encasing it in square brackets, followed by a URL in parentheses. A title (text displayed on hover) may optionally be included as well. + +```no-highlight +Here's an [example](https://www.example.com) of a link. + +And here's [another link](https://www.example.com "Click me!"), this time with a title. +``` + +Here's an [example](https://www.example.com) of a link. + +And here's [another link](https://www.example.com "Click me!"), with a title. + +## Images + +The syntax for embedding an image is very similar to that used for a hyperlink. Alternate text should always be provided; this will be displayed if the image fails to load. As with hyperlinks, title text is optional. + +```no-highlight +![Alternate text](/path/to/image.png "Image title text") +``` + +## Code Blocks + +Single backticks can be used to annotate code inline. Text enclosed by lines of three backticks will be displayed as a code block. + +```no-highlight +Paragraphs are rendered in HTML using `

` and `

` tags. +``` + +Paragraphs are rendered in HTML using `

` and `

` tags. + +```` +``` +def my_func(foo, bar): + # Do something + return foo * bar +``` +```` + +```no-highlight +def my_func(foo, bar): + # Do something + return foo * bar +``` + +## Tables + +Simple tables can be constructed using the pipe character (`|`) to denote columns, and hyphens (`-`) to denote the heading. Inline Markdown can be used to style text within columns. + +```no-highlight +| Heading 1 | Heading 2 | Heading 3 | +|-----------|-----------|-----------| +| Row 1 | Alpha | Red | +| Row 2 | **Bravo** | Green | +| Row 3 | Charlie | ~~Blue~~ | +``` + +| Heading 1 | Heading 2 | Heading 3 | +|-----------|-----------|-----------| +| _Row 1_ | Alpha | Red | +| Row 2 | **Bravo** | Green | +| Row 3 | Charlie | ~~Blue~~ | + +Colons can be used to align text to the left or right side of a column. + +```no-highlight +| Left-aligned | Centered | Right-aligned | +|:-------------|:--------:|--------------:| +| Text | Text | Text | +| Text | Text | Text | +| Text | Text | Text | +``` + +| Left-aligned | Centered | Right-aligned | +|:-------------|:--------:|--------------:| +| Text | Text | Text | +| Text | Text | Text | +| Text | Text | Text | + +## Blockquotes + +Text can be wrapped in a blockquote by prepending a right angle bracket (`>`) before each line. + +```no-highlight +> I think that I shall never see +> a graph more lovely than a tree. +> A tree whose crucial property +> is loop-free connectivity. +``` + +> I think that I shall never see +> a graph more lovely than a tree. +> A tree whose crucial property +> is loop-free connectivity. + +Markdown removes line breaks by default. To preserve line breaks, append two spaces to each line (represented below with the `⋅` character). + +```no-highlight +> I think that I shall never see⋅⋅ +> a graph more lovely than a tree.⋅⋅ +> A tree whose crucial property⋅⋅ +> is loop-free connectivity. +``` + +> I think that I shall never see +> a graph more lovely than a tree. +> A tree whose crucial property +> is loop-free connectivity. + +## Horizontal Rule + +A horizontal rule is a single line rendered across the width of the page using a series of three or more hyphens or asterisks. It can be useful for separating sections of content. + +```no-highlight +Content + +--- + +More content + +*** + +Final content +``` + +Content + +--- + +More content + +*** + +Final content diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md index f01d3160f..b1f23ae55 100644 --- a/docs/release-notes/index.md +++ b/docs/release-notes/index.md @@ -10,6 +10,14 @@ Minor releases are published in April, August, and December of each calendar yea This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release. +#### [Version 4.0](./version-4.0.md) (April 2024) + +* Complete UI Refresh ([#12128](https://github.com/netbox-community/netbox/issues/12128)) +* Dynamic REST API Fields ([#15087](https://github.com/netbox-community/netbox/issues/15087)) +* Strawberry GraphQL Engine ([#9856](https://github.com/netbox-community/netbox/issues/9856)) +* Advanced Form Rendering Functionality ([#14739](https://github.com/netbox-community/netbox/issues/14739)) +* Legacy Admin UI Disabled ([#12325](https://github.com/netbox-community/netbox/issues/12325)) + #### [Version 3.7](./version-3.7.md) (December 2023) * VPN Tunnels ([#9816](https://github.com/netbox-community/netbox/issues/9816)) diff --git a/docs/release-notes/version-3.7.md b/docs/release-notes/version-3.7.md index 21e7489c3..caf8b65e4 100644 --- a/docs/release-notes/version-3.7.md +++ b/docs/release-notes/version-3.7.md @@ -1,6 +1,118 @@ # NetBox v3.7 -## v3.7.4 (FUTURE) +## v3.7.8 (2024-05-06) + +### Enhancements + +* [#12127](https://github.com/netbox-community/netbox/issues/12127) - Enable adding new cables directly from navigation menu + +### Bug Fixes + +* [#15877](https://github.com/netbox-community/netbox/issues/15877) - Account for virtual chassis membership when assigning related interfaces via bulk edit +* [#15917](https://github.com/netbox-community/netbox/issues/15917) - Fix pagination through search results within dropdown fields +* [#15925](https://github.com/netbox-community/netbox/issues/15925) - Fix SVG rendering of cable traces to circuit terminations +* [#15948](https://github.com/netbox-community/netbox/issues/15948) - Fix cable trace SVG generation for cables with multiple terminations at both ends +* [#15960](https://github.com/netbox-community/netbox/issues/15960) - Replace CSV export formatting for several many-to-many fields +* [#15961](https://github.com/netbox-community/netbox/issues/15961) - Fix secret toggle button for IKE policies + +--- + +## v3.7.7 (2024-05-01) + +### Enhancements + +* [#15428](https://github.com/netbox-community/netbox/issues/15428) - Show usage counts for associated objects on config template list +* [#15812](https://github.com/netbox-community/netbox/issues/15812) - Add Date & DateTime variable types for custom scripts +* [#15894](https://github.com/netbox-community/netbox/issues/15894) - Cache the generated API schema definition for shorter loading times + +### Bug Fixes + +* [#11460](https://github.com/netbox-community/netbox/issues/11460) - Fix AttributeError exception when editing a cable with only one end terminated +* [#13712](https://github.com/netbox-community/netbox/issues/13712) - Fix row highlighting for device interface list display +* [#13806](https://github.com/netbox-community/netbox/issues/13806) - Fix "mark" button tooltip on button activation for device interface list display +* [#13922](https://github.com/netbox-community/netbox/issues/13922) - Fix SVG drawing error on multiple termination trace with multiple devices +* [#14241](https://github.com/netbox-community/netbox/issues/14241) - Fix random interface swap when performing cable trace with multiple termination +* [#14852](https://github.com/netbox-community/netbox/issues/14852) - Fix NoReverseMatch exception when viewing an event rule which references a deleted custom script +* [#15524](https://github.com/netbox-community/netbox/issues/15524) - Fix rounding error when reporting IP range utilization +* [#15548](https://github.com/netbox-community/netbox/issues/15548) - Ignore many-to-many mappings when checking dependencies of an object being deleted +* [#15845](https://github.com/netbox-community/netbox/issues/15845) - Avoid extraneous database queries when fetching assigned IP addresses via REST API +* [#15872](https://github.com/netbox-community/netbox/issues/15872) - `BANNER_MAINTENANCE` content should permit custom HTML +* [#15891](https://github.com/netbox-community/netbox/issues/15891) - Ensure deterministic ordering for scripts & reports +* [#15896](https://github.com/netbox-community/netbox/issues/15896) - Fix retention of default value when editing a custom JSON field +* [#15899](https://github.com/netbox-community/netbox/issues/15899) - Fix exception when enabling the tags column on the L2VPN terminations table + +--- + +## v3.7.6 (2024-04-22) + +!!! warning + If remote authentication is in use with Gunicorn v22.0 or later, it may be necessary to configure Gunicorn's [`header_map`](https://docs.gunicorn.org/en/stable/settings.html#header-map) setting to preserve authentication headers. + +### Enhancements + +* [#14690](https://github.com/netbox-community/netbox/issues/14690) - Improve rendering of JSON data in configuration form +* [#15427](https://github.com/netbox-community/netbox/issues/15427) - Enable compatibility with non-Amazon S3 providers for remote data sources +* [#15640](https://github.com/netbox-community/netbox/issues/15640) - Add global search support for L2VPN identifiers +* [#15644](https://github.com/netbox-community/netbox/issues/15644) - Introduce new configuration parameters for enabling HTTP Strict Transport Security (HSTS) + +### Bug Fixes + +* [#15541](https://github.com/netbox-community/netbox/issues/15541) - Restore ability to modify assigned component template when adding/modifying an inventory item template +* [#15582](https://github.com/netbox-community/netbox/issues/15582) - Fix permission constraints for synchronization of remote data sources +* [#15588](https://github.com/netbox-community/netbox/issues/15588) - Correct OpenAPI schema definitions for read-only fields which may return null values +* [#15635](https://github.com/netbox-community/netbox/issues/15635) - Extend plugin removal instruction to include reindexing the global search cache +* [#15654](https://github.com/netbox-community/netbox/issues/15654) - Fix `AttributeError` exception when attempting to save an incomplete tunnel termination +* [#15668](https://github.com/netbox-community/netbox/issues/15668) - Fix permission required to display virtual disks tab on virtual machine UI view +* [#15685](https://github.com/netbox-community/netbox/issues/15685) - Allow filtering cables by decimal values using UI filter form +* [#15761](https://github.com/netbox-community/netbox/issues/15761) - Add missing `ike_policy` & `ike_policy_id` filters for IKE proposals +* [#15771](https://github.com/netbox-community/netbox/issues/15771) - Include `id` in list of supported fields for all bulk import forms +* [#15790](https://github.com/netbox-community/netbox/issues/15790) - Fix live preview support for EventRule comments + +--- + +## v3.7.5 (2024-04-04) + +### Enhancements + +* [#14707](https://github.com/netbox-community/netbox/issues/14707) - Clarify interface designation when creating tunnel terminations +* [#15039](https://github.com/netbox-community/netbox/issues/15039) - Allow API tokens to be cloned + +### Bug Fixes + +* [#14799](https://github.com/netbox-community/netbox/issues/14799) - Avoid caching modified reports & scripts +* [#15029](https://github.com/netbox-community/netbox/issues/15029) - Raise a clean validation error when attempting to make duplicate FHRP group assignments +* [#15102](https://github.com/netbox-community/netbox/issues/15102) - Fix usage of selector widget for form fields referencing users/groups +* [#15435](https://github.com/netbox-community/netbox/issues/15435) - Correct permissions name to allow adding a module bay to a device via the UI +* [#15502](https://github.com/netbox-community/netbox/issues/15502) - Fix KeyError exception when modifying an IP address assigned to a virtual machine +* [#15597](https://github.com/netbox-community/netbox/issues/15597) - Restore help modal for `button_class` field on custom link bulk import form +* [#15598](https://github.com/netbox-community/netbox/issues/15598) - Fix exception when creating a device from a device type with one or more child inventory items +* [#15608](https://github.com/netbox-community/netbox/issues/15608) - Avoid caching values of null fields in search index +* [#15609](https://github.com/netbox-community/netbox/issues/15609) - Fix filtering of the providers list by assigned ASN + +--- + +## v3.7.4 (2024-03-13) + +### Enhancements + +* [#14206](https://github.com/netbox-community/netbox/issues/14206) - Add additional FibreChannel SFP+ interface types +* [#14366](https://github.com/netbox-community/netbox/issues/14366) - Enable custom links for config contexts & templates +* [#15291](https://github.com/netbox-community/netbox/issues/15291) - Add tunnel termination buttons to VM interfaces table +* [#15297](https://github.com/netbox-community/netbox/issues/15297) - Linkify platform column in device & virtual machine tables + +### Bug Fixes + +* [#13722](https://github.com/netbox-community/netbox/issues/13722) - Fix range expansion for comma-separated numerical values +* [#14832](https://github.com/netbox-community/netbox/issues/14832) - Enable querying IP addresses for an FHRP group via GraphQL +* [#15220](https://github.com/netbox-community/netbox/issues/15220) - Fix validation check when bulk editing the mask length of IP addresses +* [#15232](https://github.com/netbox-community/netbox/issues/15232) - Permit user with sufficient permissions to assign an inventory item to a device type +* [#15241](https://github.com/netbox-community/netbox/issues/15241) - Restore missing `display` field on VirtualDisk serialization in REST API +* [#15243](https://github.com/netbox-community/netbox/issues/15243) - Correct representation of installed module when listing module bays using REST API brief mode +* [#15316](https://github.com/netbox-community/netbox/issues/15316) - Fix selection of 3DES encryption for IKE & IPSec proposals +* [#15322](https://github.com/netbox-community/netbox/issues/15322) - Add description field to YAML export for device & module types +* [#15336](https://github.com/netbox-community/netbox/issues/15336) - Correct label for recurring scheduled jobs +* [#15347](https://github.com/netbox-community/netbox/issues/15347) - Fix querying virtual machine contacts via GraphQL +* [#15356](https://github.com/netbox-community/netbox/issues/15356) - Fix assignment of front & rear images to device types via REST API --- diff --git a/docs/release-notes/version-4.0.md b/docs/release-notes/version-4.0.md new file mode 100644 index 000000000..d837d0cf2 --- /dev/null +++ b/docs/release-notes/version-4.0.md @@ -0,0 +1,278 @@ +# NetBox v4.0 + +## v4.0.4 (FUTURE) + +### Enhancements + +* [#14810](https://github.com/netbox-community/netbox/issues/14810) - Enable contact assignment for services +* [#15489](https://github.com/netbox-community/netbox/issues/15489) - Add 1000Base-TX interface type +* [#16290](https://github.com/netbox-community/netbox/issues/16290) - Capture entire object in changelog data (but continue to display only non-internal attributes) + +### Bug Fixes + +* [#13422](https://github.com/netbox-community/netbox/issues/13422) - Rebuild MPTT trees for applicable models after merging staged changes +* [#16202](https://github.com/netbox-community/netbox/issues/16202) - Fix site map button URL for certain localizations +* [#16286](https://github.com/netbox-community/netbox/issues/16286) - Fix global search support for provider accounts + +--- + +## v4.0.3 (2024-05-22) + +### Enhancements + +* [#12984](https://github.com/netbox-community/netbox/issues/12984) - Add Molex Micro-Fit power port & outlet types +* [#13764](https://github.com/netbox-community/netbox/issues/13764) - Enable contact assignments for aggregates, prefixes, IP ranges, and IP addresses +* [#14639](https://github.com/netbox-community/netbox/issues/14639) - Add Ukrainian translation support +* [#14653](https://github.com/netbox-community/netbox/issues/14653) - Add an inventory items table column for all device components +* [#14686](https://github.com/netbox-community/netbox/issues/14686) - Add German translation support +* [#14855](https://github.com/netbox-community/netbox/issues/14855) - Add Chinese translation support +* [#14948](https://github.com/netbox-community/netbox/issues/14948) - Introduce the `has_virtual_device_context` filter for devices +* [#15353](https://github.com/netbox-community/netbox/issues/15353) - Improve error reporting when custom scripts fail to load +* [#15496](https://github.com/netbox-community/netbox/issues/15496) - Implement dedicated views for management of circuit terminations +* [#15603](https://github.com/netbox-community/netbox/issues/15603) - Add 4G & 5G cellular interface types +* [#15962](https://github.com/netbox-community/netbox/issues/15962) - Enable UNIX socket connections for Redis + +### Bug Fixes + +* [#13293](https://github.com/netbox-community/netbox/issues/13293) - Limit interface selector for IP address to current device/VM +* [#14953](https://github.com/netbox-community/netbox/issues/14953) - Ensure annotated count fields are present in REST API response data when creating new objects +* [#14982](https://github.com/netbox-community/netbox/issues/14982) - Fix OpenAPI schema definition for SerializedPKRelatedFields +* [#15082](https://github.com/netbox-community/netbox/issues/15082) - Strip whitespace from choice values & labels when creating a custom field choice set +* [#16138](https://github.com/netbox-community/netbox/issues/16138) - Fix support for referencing users & groups in object permissions +* [#16145](https://github.com/netbox-community/netbox/issues/16145) - Restore ability to reference custom scripts via module & name in REST API +* [#16164](https://github.com/netbox-community/netbox/issues/16164) - Correct display of selected values in UI when filtering object list by a null value +* [#16173](https://github.com/netbox-community/netbox/issues/16173) - Fix TypeError exception when viewing object list with no pagination preference defined +* [#16228](https://github.com/netbox-community/netbox/issues/16228) - Fix permissions enforcement for GraphQL queries of users & groups +* [#16232](https://github.com/netbox-community/netbox/issues/16232) - Preserve bulk action checkboxes on dynamic tables when using pagination +* [#16240](https://github.com/netbox-community/netbox/issues/16240) - Fixed NoReverseMatch exception when adding circuit terminations to an object counts dashboard widget + +--- + +## v4.0.2 (2024-05-14) + +!!! warning "Important" + This release includes an important security fix, and is a strongly recommended update for all users. More details will follow. + +### Enhancements + +* [#15119](https://github.com/netbox-community/netbox/issues/15119) - Add cluster & cluster group UI filter fields for VLAN groups +* [#16090](https://github.com/netbox-community/netbox/issues/16090) - Include current NetBox version when an unsupported plugin is detected +* [#16096](https://github.com/netbox-community/netbox/issues/16096) - Introduce the `ENABLE_TRANSLATION` configuration parameter +* [#16107](https://github.com/netbox-community/netbox/issues/16107) - Change the default value for `LOGIN_REQUIRED` to True +* [#16127](https://github.com/netbox-community/netbox/issues/16127) - Add integration point for unsupported settings + +### Bug Fixes + +* [#16077](https://github.com/netbox-community/netbox/issues/16077) - Fix display of parameter values when viewing configuration revisions +* [#16078](https://github.com/netbox-community/netbox/issues/16078) - Fix integer filters mistakenly marked as required for GraphQL API +* [#16101](https://github.com/netbox-community/netbox/issues/16101) - Fix initial loading of pagination widget for dynamic object tables +* [#16123](https://github.com/netbox-community/netbox/issues/16123) - Fix custom script execution via REST API +* [#16124](https://github.com/netbox-community/netbox/issues/16124) - Fix GraphQL API support for querying virtual machine interfaces + +--- + +## v4.0.1 (2024-05-09) + +### Enhancements + +* [#15148](https://github.com/netbox-community/netbox/issues/15148) - Add copy-to-clipboard button for config context data +* [#15328](https://github.com/netbox-community/netbox/issues/15328) - Add a virtual machines UI tab for host devices +* [#15451](https://github.com/netbox-community/netbox/issues/15451) - Add 2.5 and 5 Gbps backplane Ethernet interface types +* [#16010](https://github.com/netbox-community/netbox/issues/16010) - Enable Prometheus middleware only if metrics are enabled + +### Bug Fixes + +* [#15968](https://github.com/netbox-community/netbox/issues/15968) - Avoid resizing quick search field to display clear button +* [#15973](https://github.com/netbox-community/netbox/issues/15973) - Fix AttributeError exception when modifying cable termination type +* [#15977](https://github.com/netbox-community/netbox/issues/15977) - Hide all admin menu items for non-authenticated users +* [#15982](https://github.com/netbox-community/netbox/issues/15982) - Restore the "assign IP" tab for assigning existing IP addresses to interfaces +* [#15992](https://github.com/netbox-community/netbox/issues/15992) - Fix AttributeError exception when Sentry integration is enabled +* [#15995](https://github.com/netbox-community/netbox/issues/15995) - Permit nullable fields referenced by unique constraints to be omitted from REST API requests +* [#15999](https://github.com/netbox-community/netbox/issues/15999) - Fix layout of login form labels for certain languages +* [#16003](https://github.com/netbox-community/netbox/issues/16003) - Enable cache busting for `setmode.js` asset to avoid breaking dark mode support on upgrade +* [#16011](https://github.com/netbox-community/netbox/issues/16011) - Fix site tenant assignment by PK via REST API +* [#16020](https://github.com/netbox-community/netbox/issues/16020) - Include Python version in system UI view +* [#16022](https://github.com/netbox-community/netbox/issues/16022) - Fix database migration failure when encountering a script module which no longer exists on disk +* [#16025](https://github.com/netbox-community/netbox/issues/16025) - Fix execution of scripts via the `runscript` management command +* [#16031](https://github.com/netbox-community/netbox/issues/16031) - Render Markdown content in script log messages +* [#16051](https://github.com/netbox-community/netbox/issues/16051) - Translate "empty" text for object tables +* [#16061](https://github.com/netbox-community/netbox/issues/16061) - Omit hidden fields from display within event rule edit form + +--- + +## v4.0.0 (2024-05-06) + +!!! tip "Plugin Maintainers" + Please see the dedicated [plugin migration guide](../plugins/development/migration-v4.md) for a checklist of changes that may be needed to ensure compatibility with NetBox v4.0. + +### Breaking Changes + +* Support for Python 3.8 and 3.9 has been removed. +* The format for GraphQL query filters has changed. Please see the GraphQL documentation for details and examples. +* The deprecated `device_role` & `device_role_id` filters for devices have been removed. (Use `role` and `role_id` instead.) +* The obsolete `device_role` field has been removed from the REST API serializer for devices. (Use `role` instead.) +* The legacy reports functionality has been dropped. Reports will be automatically converted to custom scripts on upgrade. +* The `parent` and `parent_id` filters for locations now return only immediate children of the specified location. (Use `ancestor` and `ancestor_id` to return _all_ descendants.) +* The `object_type` field on the CustomField model has been renamed to `related_object_type`. +* The `utilities.utils` module has been removed and its resources reorganized into separate modules organized by function. +* The obsolete `NullableCharField` class has been removed. (Use Django's stock `CharField` class with `null=True` instead.) +* The `annotated_date` template filter and `annotated_now` template tag have been removed. + +### New Features + +#### Complete UI Refresh ([#12128](https://github.com/netbox-community/netbox/issues/12128)) + +The NetBox user interface has been completely refreshed and updated. This massive effort entailed: + +* Refactoring the base HTML templates +* Moving from Boostrap 5.0 to Bootstrap 5.3 +* Adopting the [Tabler](https://tabler.io/) UI theme +* Replacing slim-select with [Tom-Select](https://tom-select.js.org/) +* Displaying additional object attributes in dropdown form fields +* Enabling opt-in HTMX-powered navigation (see [#14736](https://github.com/netbox-community/netbox/issues/14736)) +* Widespread cleanup & standardization of UI components + +#### Dynamic REST API Fields ([#15087](https://github.com/netbox-community/netbox/issues/15087)) + +The REST API now supports specifying which fields to include in the response data. For example, the response to a request for + +``` +GET /api/dcim/sites/?fields=name,status,region,tenant +``` + +will include only the four specified fields in the representation of each site. Additionally, the underlying database queries effected by such requests have been optimized to omit fields which are not included in the response, resulting in a substantial performance improvement. + +#### Strawberry GraphQL Engine ([#9856](https://github.com/netbox-community/netbox/issues/9856)) + +The GraphQL engine has been changed from using Graphene-Django to Strawberry-Django. Changes include: + +* Queryset Optimizer - reduces the number of database queries when querying related tables +* Updated GraphiQL Browser +* The format for GraphQL query filters and lookups has changed. Please see the GraphQL documentation for details and examples. + +#### Advanced Form Rendering Functionality ([#14739](https://github.com/netbox-community/netbox/issues/14739)) + +New resources have been introduced to enable advanced form rendering without a need for custom HTML templates. These include: + +* FieldSet - Represents a grouping of form fields (replaces the use of lists/tuples) +* InlineFields - Multiple fields rendered on a single row +* TabbedGroups - Fieldsets rendered under navigable tabs within a form +* ObjectAttribute - Renders a read-only representation of a particular object attribute (for reference) + +#### Legacy Admin UI Disabled ([#12325](https://github.com/netbox-community/netbox/issues/12325)) + +The legacy admin user interface is now disabled by default, and the few remaining views it provided have been relocated to the primary UI. NetBox deployments which still depend on the legacy admin functionality for plugins can enable it by setting the `DJANGO_ADMIN_ENABLED` configuration parameter to true. + +### Enhancements + +* [#12776](https://github.com/netbox-community/netbox/issues/12776) - Introduce the `htmx_table` template tag to simplify the rendering of embedded tables +* [#12851](https://github.com/netbox-community/netbox/issues/12851) - Replace the deprecated Bleach HTML sanitization library with nh3 +* [#13283](https://github.com/netbox-community/netbox/issues/13283) - Display additional context on API-backed dropdown form fields (e.g. object descriptions) +* [#13918](https://github.com/netbox-community/netbox/issues/13918) - Add `facility` field to Location model +* [#14237](https://github.com/netbox-community/netbox/issues/14237) - Automatically clear dependent selection form fields when modifying a parent selection +* [#14279](https://github.com/netbox-community/netbox/issues/14279) - Make the current request available as context when running custom validators +* [#14454](https://github.com/netbox-community/netbox/issues/14454) - Include member devices in the REST API representation of virtual chassis +* [#14637](https://github.com/netbox-community/netbox/issues/14637) - Upgrade to Django 5.0 +* [#14672](https://github.com/netbox-community/netbox/issues/14672) - Add support for Python 3.12 +* [#14728](https://github.com/netbox-community/netbox/issues/14728) - The plugins list view has been moved from the legacy admin UI to the main NetBox UI +* [#14729](https://github.com/netbox-community/netbox/issues/14729) - All background task views have been moved from the legacy admin UI to the main NetBox UI +* [#14736](https://github.com/netbox-community/netbox/issues/14736) - Introduce a user preference to enable HTMX-powered navigation +* [#14438](https://github.com/netbox-community/netbox/issues/14438) - Track individual custom scripts as database objects +* [#15131](https://github.com/netbox-community/netbox/issues/15131) - Automatically annotate related object counts on REST API querysets +* [#15237](https://github.com/netbox-community/netbox/issues/15237) - Ensure consistent filtering ability for all model fields by testing for missing/incorrect filters +* [#15238](https://github.com/netbox-community/netbox/issues/15238) - Include the `description` field in "brief" REST API serializations +* [#15278](https://github.com/netbox-community/netbox/issues/15278) - BaseModelSerializer now takes a `nested` keyword argument allowing it to represent a related object +* [#15383](https://github.com/netbox-community/netbox/issues/15383) - Standardize filtering logic for the parents of recursively-nested models (parent & ancestor filters) +* [#15413](https://github.com/netbox-community/netbox/issues/15413) - The global search engine now supports caching of non-field object attributes +* [#15490](https://github.com/netbox-community/netbox/issues/15490) - Custom validators can now reference related object attributes via dotted paths +* [#15547](https://github.com/netbox-community/netbox/issues/15547) - Add comments field to CustomField model +* [#15712](https://github.com/netbox-community/netbox/issues/15712) - Enable image attachments for virtual machines +* [#15735](https://github.com/netbox-community/netbox/issues/15735) - Display all dates & times in ISO 8601 format consistently +* [#15754](https://github.com/netbox-community/netbox/issues/15754) - Remove `is_staff` restriction on admin menu items +* [#15764](https://github.com/netbox-community/netbox/issues/15764) - Increase maximum value of Device `vc_position` field +* [#15915](https://github.com/netbox-community/netbox/issues/15915) - Provide a comprehensive system status view with export functionality + +### Bug Fixes (from Beta2) + +* [#15630](https://github.com/netbox-community/netbox/issues/15630) - Ensure consistent toggling between light & dark UI modes +* [#15802](https://github.com/netbox-community/netbox/issues/15802) - Improve hyperlink color contrast in dark mode +* [#15809](https://github.com/netbox-community/netbox/issues/15809) - Fix GraphQL union support for nullable fields +* [#15815](https://github.com/netbox-community/netbox/issues/15815) - Convert dashboard widgets referencing old user/group models +* [#15826](https://github.com/netbox-community/netbox/issues/15826) - Update `EXEMPT_EXCLUDE_MODELS` to reference new user & group models +* [#15831](https://github.com/netbox-community/netbox/issues/15831) - Fix LDAP group mirroring +* [#15838](https://github.com/netbox-community/netbox/issues/15838) - Fix AttributeError exception when rendering custom date fields +* [#15852](https://github.com/netbox-community/netbox/issues/15852) - Update total results count when filtering object lists +* [#15853](https://github.com/netbox-community/netbox/issues/15853) - Correct background color for cable trace SVG images in dark mode +* [#15855](https://github.com/netbox-community/netbox/issues/15855) - Fix AttributeError exception when creating an event rule tied to a custom script +* [#15944](https://github.com/netbox-community/netbox/issues/15944) - Fix styling of paginator when displayed above an object list + +### Other Changes + +* [#10587](https://github.com/netbox-community/netbox/issues/10587) - Enable pagination and filtering for custom script logs +* [#12325](https://github.com/netbox-community/netbox/issues/12325) - The Django admin UI is now disabled by default (set `DJANGO_ADMIN_ENABLED` to True to enable it) +* [#12510](https://github.com/netbox-community/netbox/issues/12510) - Dropped support for legacy reports +* [#12795](https://github.com/netbox-community/netbox/issues/12795) - NetBox now uses custom User and Group models rather than the stock models provided by Django +* [#13647](https://github.com/netbox-community/netbox/issues/13647) - Squash all database migrations prior to v3.7 +* [#14092](https://github.com/netbox-community/netbox/issues/14092) - Remove backward compatibility for importing plugin resources from `extras.plugins` (now `netbox.plugins`) +* [#14638](https://github.com/netbox-community/netbox/issues/14638) - Drop support for Python 3.8 and 3.9 +* [#14657](https://github.com/netbox-community/netbox/issues/14657) - Remove backward compatibility for old permissions mapping under `ActionsMixin` +* [#14658](https://github.com/netbox-community/netbox/issues/14658) - Remove backward compatibility for importing `process_webhook()` from `extras.webhooks_worker` (now `extras.webhooks.send_webhook()`) +* [#14740](https://github.com/netbox-community/netbox/issues/14740) - Remove the obsolete `BootstrapMixin` form mixin class +* [#15042](https://github.com/netbox-community/netbox/issues/15042) - The logic for registering models & model features now executes under the `ready()` method of individual app configs, rather than relying on the `class_prepared` signal +* [#15099](https://github.com/netbox-community/netbox/issues/15099) - Remove obsolete `device_role` and `device_role_id` filters for devices +* [#15100](https://github.com/netbox-community/netbox/issues/15100) - Remove obsolete `NullableCharField` class +* [#15154](https://github.com/netbox-community/netbox/issues/15154) - The installation documentation been extended to include instructions and an example configuration file for uWSGI as an alternative to gunicorn +* [#15193](https://github.com/netbox-community/netbox/issues/15193) - Switch to compiled distribution of the `psycopg` library +* [#15277](https://github.com/netbox-community/netbox/issues/15277) - Replace references to ContentType without ObjectType proxy model & standardize field names +* [#15292](https://github.com/netbox-community/netbox/issues/15292) - Remove obsolete `device_role` attribute from Device model (this field was renamed to `role` in v3.6) +* [#15357](https://github.com/netbox-community/netbox/issues/15357) - The `object_type` field on the CustomField model has been renamed to `related_object_type` to avoid confusion with its `object_types` field +* [#15401](https://github.com/netbox-community/netbox/issues/15401) - PostgreSQL indexes and sequence tables for the relocated L2VPN models (see [#14311](https://github.com/netbox-community/netbox/issues/14311)) have been renamed +* [#15462](https://github.com/netbox-community/netbox/issues/15462) - Relocate resources from the `utilities.utils` module +* [#15464](https://github.com/netbox-community/netbox/issues/15464) - The many-to-many relationships for ObjectPermission are now defined on the custom User and Group models +* [#15736](https://github.com/netbox-community/netbox/issues/15736) - Remove obsolete `annotated_date` template filter & `annotated_now` template tag +* [#15738](https://github.com/netbox-community/netbox/issues/15738) - Remove obsolete configuration parameters for date & time formatting +* [#15752](https://github.com/netbox-community/netbox/issues/15752) - Remove the obsolete `ENABLE_LOCALIZATION` configuration parameter +* [#15942](https://github.com/netbox-community/netbox/issues/15942) - Refactor `settings_and_registry()` context processor + +### REST API Changes + +* The `/api/extras/content-types/` endpoint has moved to `/api/extras/object-types/` +* The `/api/extras/reports/` endpoint has been removed +* The `description` field is now included by default when using "brief mode" for all relevant models +* dcim.Device + * The obsolete read-only attribute `device_role` has been removed (replaced by `role` in v3.6) +* dcim.Location + * Added the optional `location` field +* dcim.VirtualChassis + * Added `members` field to list the member devices +* extras.CustomField + * `content_types` has been renamed to `object_types` + * `object_type` has been renamed to `related_object_type` + * The `content_types` filter is now `object_type` + * The `content_type_id` filter is now `object_type_id` +* extras.CustomLink + * `content_types` has been renamed to `object_types` + * The `content_types` filter is now `object_type` + * The `content_type_id` filter is now `object_type_id` +* extras.EventRule + * `content_types` has been renamed to `object_types` + * The `content_types` filter is now `object_type` + * The `content_type_id` filter is now `object_type_id` +* extras.ExportTemplate + * `content_types` has been renamed to `object_types` + * The `content_types` filter is now `object_type` + * The `content_type_id` filter is now `object_type_id` +* extras.ImageAttachment + * `content_type` has been renamed to `object_type` + * The `content_type` filter is now `object_type` +* extras.SavedFilter + * `content_types` has been renamed to `object_types` + * The `content_types` filter is now `object_type` + * The `content_type_id` filter is now `object_type_id` +* tenancy.ContactAssignment + * `content_type` has been renamed to `object_type` + * The `content_type_id` filter is now `object_type_id` +* users.Group + * Added the `permissions` field +* users.User + * Added the `permissions` field diff --git a/mkdocs.yml b/mkdocs.yml index e1128578a..cf1e66cea 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -42,6 +42,7 @@ plugins: show_root_toc_entry: false show_source: false extra: + build_public: !ENV BUILD_PUBLIC social: - icon: fontawesome/brands/github link: https://github.com/netbox-community/netbox @@ -52,6 +53,7 @@ extra_css: markdown_extensions: - admonition - attr_list + - footnotes - pymdownx.emoji: emoji_index: !!python/name:material.extensions.emoji.twemoji emoji_generator: !!python/name:material.extensions.emoji.to_svg @@ -93,7 +95,8 @@ nav: - 1. PostgreSQL: 'installation/1-postgresql.md' - 2. Redis: 'installation/2-redis.md' - 3. NetBox: 'installation/3-netbox.md' - - 4. Gunicorn: 'installation/4-gunicorn.md' + - 4a. Gunicorn: 'installation/4a-gunicorn.md' + - 4b. uWSGI: 'installation/4b-uwsgi.md' - 5. HTTP Server: 'installation/5-http-server.md' - 6. LDAP (Optional): 'installation/6-ldap.md' - Upgrading NetBox: 'installation/upgrading.md' @@ -110,7 +113,6 @@ nav: - Default Values: 'configuration/default-values.md' - Error Reporting: 'configuration/error-reporting.md' - Plugins: 'configuration/plugins.md' - - Date & Time: 'configuration/date-time.md' - Miscellaneous: 'configuration/miscellaneous.md' - Development: 'configuration/development.md' - Customization: @@ -127,7 +129,9 @@ nav: - Synchronized Data: 'integrations/synchronized-data.md' - Prometheus Metrics: 'integrations/prometheus-metrics.md' - Plugins: - - Using Plugins: 'plugins/index.md' + - About Plugins: 'plugins/index.md' + - Installing a Plugin: 'plugins/installation.md' + - Removing a Plugin: 'plugins/removal.md' - Developing Plugins: - Getting Started: 'plugins/development/index.md' - Models: 'plugins/development/models.md' @@ -145,6 +149,7 @@ nav: - Dashboard Widgets: 'plugins/development/dashboard-widgets.md' - Staged Changes: 'plugins/development/staged-changes.md' - Exceptions: 'plugins/development/exceptions.md' + - Migrating to v4.0: 'plugins/development/migration-v4.md' - Administration: - Authentication: - Overview: 'administration/authentication/overview.md' @@ -291,6 +296,7 @@ nav: - git Cheat Sheet: 'development/git-cheat-sheet.md' - Release Notes: - Summary: 'release-notes/index.md' + - Version 4.0: 'release-notes/version-4.0.md' - Version 3.7: 'release-notes/version-3.7.md' - Version 3.6: 'release-notes/version-3.6.md' - Version 3.5: 'release-notes/version-3.5.md' diff --git a/netbox/account/tables.py b/netbox/account/tables.py index 6655a7f82..bcc0a0ccd 100644 --- a/netbox/account/tables.py +++ b/netbox/account/tables.py @@ -30,10 +30,12 @@ class UserTokenTable(NetBoxTable): write_enabled = columns.BooleanColumn( verbose_name=_('Write Enabled') ) - created = columns.DateColumn( + created = columns.DateTimeColumn( + timespec='minutes', verbose_name=_('Created'), ) - expires = columns.DateColumn( + expires = columns.DateTimeColumn( + timespec='minutes', verbose_name=_('Expires'), ) last_used = columns.DateTimeColumn( diff --git a/netbox/account/views.py b/netbox/account/views.py index 3dbba9b29..40ce78039 100644 --- a/netbox/account/views.py +++ b/netbox/account/views.py @@ -2,8 +2,8 @@ import logging from django.conf import settings from django.contrib import messages -from django.contrib.auth import login as auth_login, logout as auth_logout -from django.contrib.auth import update_session_auth_hash +from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash +from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import update_last_login from django.contrib.auth.signals import user_logged_in @@ -72,7 +72,7 @@ class LoginView(View): return auth_backends def get(self, request): - form = forms.LoginForm(request) + form = AuthenticationForm(request) if request.user.is_authenticated: logger = logging.getLogger('netbox.auth.login') @@ -85,7 +85,7 @@ class LoginView(View): def post(self, request): logger = logging.getLogger('netbox.auth.login') - form = forms.LoginForm(request, data=request.POST) + form = AuthenticationForm(request, data=request.POST) if form.is_valid(): logger.debug("Login form validation was successful") @@ -220,7 +220,7 @@ class ChangePasswordView(LoginRequiredMixin, View): messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.") return redirect('account:profile') - form = forms.PasswordChangeForm(user=request.user) + form = PasswordChangeForm(user=request.user) return render(request, self.template_name, { 'form': form, @@ -228,7 +228,7 @@ class ChangePasswordView(LoginRequiredMixin, View): }) def post(self, request): - form = forms.PasswordChangeForm(user=request.user, data=request.POST) + form = PasswordChangeForm(user=request.user, data=request.POST) if form.is_valid(): form.save() update_session_auth_hash(request, form.user) diff --git a/netbox/circuits/api/nested_serializers.py b/netbox/circuits/api/nested_serializers.py index c7aa583fa..36254dc8b 100644 --- a/netbox/circuits/api/nested_serializers.py +++ b/netbox/circuits/api/nested_serializers.py @@ -1,8 +1,8 @@ -from drf_spectacular.utils import extend_schema_field, extend_schema_serializer -from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_serializer from rest_framework import serializers from circuits.models import * +from netbox.api.fields import RelatedObjectCountField from netbox.api.serializers import WritableNestedSerializer __all__ = [ @@ -36,7 +36,7 @@ class NestedProviderNetworkSerializer(WritableNestedSerializer): ) class NestedProviderSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail') - circuit_count = serializers.IntegerField(read_only=True) + circuit_count = RelatedObjectCountField('circuits') class Meta: model = Provider @@ -64,7 +64,7 @@ class NestedProviderAccountSerializer(WritableNestedSerializer): ) class NestedCircuitTypeSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') - circuit_count = serializers.IntegerField(read_only=True) + circuit_count = RelatedObjectCountField('circuits') class Meta: model = CircuitType diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 5223de339..5e048218c 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -1,137 +1,3 @@ -from rest_framework import serializers - -from circuits.choices import CircuitStatusChoices -from circuits.models import * -from dcim.api.nested_serializers import NestedSiteSerializer -from dcim.api.serializers import CabledObjectSerializer -from ipam.models import ASN -from ipam.api.nested_serializers import NestedASNSerializer -from netbox.api.fields import ChoiceField, SerializedPKRelatedField -from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer -from tenancy.api.nested_serializers import NestedTenantSerializer +from .serializers_.providers import * +from .serializers_.circuits import * from .nested_serializers import * - - -# -# Providers -# - -class ProviderSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail') - accounts = SerializedPKRelatedField( - queryset=ProviderAccount.objects.all(), - serializer=NestedProviderAccountSerializer, - required=False, - many=True - ) - asns = SerializedPKRelatedField( - queryset=ASN.objects.all(), - serializer=NestedASNSerializer, - required=False, - many=True - ) - - # Related object counts - circuit_count = serializers.IntegerField(read_only=True) - - class Meta: - model = Provider - fields = [ - 'id', 'url', 'display', 'name', 'slug', 'accounts', 'description', 'comments', 'asns', 'tags', - 'custom_fields', 'created', 'last_updated', 'circuit_count', - ] - - -# -# Provider Accounts -# - -class ProviderAccountSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provideraccount-detail') - provider = NestedProviderSerializer() - - class Meta: - model = ProviderAccount - fields = [ - 'id', 'url', 'display', 'provider', 'name', 'account', 'description', 'comments', 'tags', 'custom_fields', - 'created', 'last_updated', - ] - - -# -# Provider networks -# - -class ProviderNetworkSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='circuits-api:providernetwork-detail') - provider = NestedProviderSerializer() - - class Meta: - model = ProviderNetwork - fields = [ - 'id', 'url', 'display', 'provider', 'name', 'service_id', 'description', 'comments', 'tags', - 'custom_fields', 'created', 'last_updated', - ] - - -# -# Circuits -# - -class CircuitTypeSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') - circuit_count = serializers.IntegerField(read_only=True) - - class Meta: - model = CircuitType - fields = [ - 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created', 'last_updated', - 'circuit_count', - ] - - -class CircuitCircuitTerminationSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') - site = NestedSiteSerializer(allow_null=True) - provider_network = NestedProviderNetworkSerializer(allow_null=True) - - class Meta: - model = CircuitTermination - fields = [ - 'id', 'url', 'display', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', - 'description', - ] - - -class CircuitSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail') - provider = NestedProviderSerializer() - provider_account = NestedProviderAccountSerializer(required=False, allow_null=True) - status = ChoiceField(choices=CircuitStatusChoices, required=False) - type = NestedCircuitTypeSerializer() - tenant = NestedTenantSerializer(required=False, allow_null=True) - termination_a = CircuitCircuitTerminationSerializer(read_only=True, allow_null=True) - termination_z = CircuitCircuitTerminationSerializer(read_only=True, allow_null=True) - - class Meta: - model = Circuit - fields = [ - 'id', 'url', 'display', 'cid', 'provider', 'provider_account', 'type', 'status', 'tenant', 'install_date', - 'termination_date', 'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags', - 'custom_fields', 'created', 'last_updated', - ] - - -class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer): - url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') - circuit = NestedCircuitSerializer() - site = NestedSiteSerializer(required=False, allow_null=True) - provider_network = NestedProviderNetworkSerializer(required=False, allow_null=True) - - class Meta: - model = CircuitTermination - fields = [ - 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', - 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', - 'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', - ] diff --git a/netbox/circuits/api/serializers_/__init__.py b/netbox/circuits/api/serializers_/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/circuits/api/serializers_/circuits.py b/netbox/circuits/api/serializers_/circuits.py new file mode 100644 index 000000000..a0d0e5e13 --- /dev/null +++ b/netbox/circuits/api/serializers_/circuits.py @@ -0,0 +1,81 @@ +from rest_framework import serializers + +from circuits.choices import CircuitStatusChoices +from circuits.models import Circuit, CircuitTermination, CircuitType +from dcim.api.serializers_.cables import CabledObjectSerializer +from dcim.api.serializers_.sites import SiteSerializer +from netbox.api.fields import ChoiceField, RelatedObjectCountField +from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer +from tenancy.api.serializers_.tenants import TenantSerializer + +from .providers import ProviderAccountSerializer, ProviderNetworkSerializer, ProviderSerializer + +__all__ = ( + 'CircuitSerializer', + 'CircuitTerminationSerializer', + 'CircuitTypeSerializer', +) + + +class CircuitTypeSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') + + # Related object counts + circuit_count = RelatedObjectCountField('circuits') + + class Meta: + model = CircuitType + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'circuit_count', + ] + brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count') + + +class CircuitCircuitTerminationSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') + site = SiteSerializer(nested=True, allow_null=True) + provider_network = ProviderNetworkSerializer(nested=True, allow_null=True) + + class Meta: + model = CircuitTermination + fields = [ + 'id', 'url', 'display', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', + 'description', + ] + + +class CircuitSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail') + provider = ProviderSerializer(nested=True) + provider_account = ProviderAccountSerializer(nested=True, required=False, allow_null=True, default=None) + status = ChoiceField(choices=CircuitStatusChoices, required=False) + type = CircuitTypeSerializer(nested=True) + tenant = TenantSerializer(nested=True, required=False, allow_null=True) + termination_a = CircuitCircuitTerminationSerializer(read_only=True, allow_null=True) + termination_z = CircuitCircuitTerminationSerializer(read_only=True, allow_null=True) + + class Meta: + model = Circuit + fields = [ + 'id', 'url', 'display', 'cid', 'provider', 'provider_account', 'type', 'status', 'tenant', 'install_date', + 'termination_date', 'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'cid', 'description') + + +class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') + circuit = CircuitSerializer(nested=True) + site = SiteSerializer(nested=True, required=False, allow_null=True) + provider_network = ProviderNetworkSerializer(nested=True, required=False, allow_null=True) + + class Meta: + model = CircuitTermination + fields = [ + 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', + 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', + 'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', + ] + brief_fields = ('id', 'url', 'display', 'circuit', 'term_side', 'description', 'cable', '_occupied') diff --git a/netbox/circuits/api/serializers_/providers.py b/netbox/circuits/api/serializers_/providers.py new file mode 100644 index 000000000..fa4489787 --- /dev/null +++ b/netbox/circuits/api/serializers_/providers.py @@ -0,0 +1,69 @@ +from rest_framework import serializers + +from circuits.models import Provider, ProviderAccount, ProviderNetwork +from ipam.api.serializers_.asns import ASNSerializer +from ipam.models import ASN +from netbox.api.fields import RelatedObjectCountField, SerializedPKRelatedField +from netbox.api.serializers import NetBoxModelSerializer +from ..nested_serializers import * + +__all__ = ( + 'ProviderAccountSerializer', + 'ProviderNetworkSerializer', + 'ProviderSerializer', +) + + +class ProviderSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail') + accounts = SerializedPKRelatedField( + queryset=ProviderAccount.objects.all(), + serializer=NestedProviderAccountSerializer, + required=False, + many=True + ) + asns = SerializedPKRelatedField( + queryset=ASN.objects.all(), + serializer=ASNSerializer, + nested=True, + required=False, + many=True + ) + + # Related object counts + circuit_count = RelatedObjectCountField('circuits') + + class Meta: + model = Provider + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'accounts', 'description', 'comments', 'asns', 'tags', + 'custom_fields', 'created', 'last_updated', 'circuit_count', + ] + brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count') + + +class ProviderAccountSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provideraccount-detail') + provider = ProviderSerializer(nested=True) + name = serializers.CharField(allow_blank=True, max_length=100, required=False, default='') + + class Meta: + model = ProviderAccount + fields = [ + 'id', 'url', 'display', 'provider', 'name', 'account', 'description', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'account', 'description') + + +class ProviderNetworkSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:providernetwork-detail') + provider = ProviderSerializer(nested=True) + + class Meta: + model = ProviderNetwork + fields = [ + 'id', 'url', 'display', 'provider', 'name', 'service_id', 'description', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index bd9431887..fffb59a57 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -4,7 +4,6 @@ from circuits import filtersets from circuits.models import * from dcim.api.views import PassThroughPortMixin from netbox.api.viewsets import NetBoxModelViewSet -from utilities.utils import count_related from . import serializers @@ -21,9 +20,7 @@ class CircuitsRootView(APIRootView): # class ProviderViewSet(NetBoxModelViewSet): - queryset = Provider.objects.prefetch_related('asns', 'tags').annotate( - circuit_count=count_related(Circuit, 'provider') - ) + queryset = Provider.objects.all() serializer_class = serializers.ProviderSerializer filterset_class = filtersets.ProviderFilterSet @@ -33,9 +30,7 @@ class ProviderViewSet(NetBoxModelViewSet): # class CircuitTypeViewSet(NetBoxModelViewSet): - queryset = CircuitType.objects.prefetch_related('tags').annotate( - circuit_count=count_related(Circuit, 'type') - ) + queryset = CircuitType.objects.all() serializer_class = serializers.CircuitTypeSerializer filterset_class = filtersets.CircuitTypeFilterSet @@ -45,9 +40,7 @@ class CircuitTypeViewSet(NetBoxModelViewSet): # class CircuitViewSet(NetBoxModelViewSet): - queryset = Circuit.objects.prefetch_related( - 'type', 'tenant', 'provider', 'provider_account', 'termination_a', 'termination_z' - ).prefetch_related('tags') + queryset = Circuit.objects.all() serializer_class = serializers.CircuitSerializer filterset_class = filtersets.CircuitFilterSet @@ -57,12 +50,9 @@ class CircuitViewSet(NetBoxModelViewSet): # class CircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet): - queryset = CircuitTermination.objects.prefetch_related( - 'circuit', 'site', 'provider_network', 'cable__terminations' - ) + queryset = CircuitTermination.objects.all() serializer_class = serializers.CircuitTerminationSerializer filterset_class = filtersets.CircuitTerminationFilterSet - brief_prefetch_fields = ['circuit'] # @@ -70,7 +60,7 @@ class CircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet): # class ProviderAccountViewSet(NetBoxModelViewSet): - queryset = ProviderAccount.objects.prefetch_related('provider', 'tags') + queryset = ProviderAccount.objects.all() serializer_class = serializers.ProviderAccountSerializer filterset_class = filtersets.ProviderAccountFilterSet @@ -80,6 +70,6 @@ class ProviderAccountViewSet(NetBoxModelViewSet): # class ProviderNetworkViewSet(NetBoxModelViewSet): - queryset = ProviderNetwork.objects.prefetch_related('tags') + queryset = ProviderNetwork.objects.all() serializer_class = serializers.ProviderNetworkSerializer filterset_class = filtersets.ProviderNetworkFilterSet diff --git a/netbox/circuits/apps.py b/netbox/circuits/apps.py index 3acf3b98c..df6804303 100644 --- a/netbox/circuits/apps.py +++ b/netbox/circuits/apps.py @@ -6,4 +6,8 @@ class CircuitsConfig(AppConfig): verbose_name = "Circuits" def ready(self): + from netbox.models.features import register_models from . import signals, search + + # Register models + register_models(*self.get_models()) diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index 97be1cf57..e52673874 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -64,10 +64,16 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): queryset=ASN.objects.all(), label=_('ASN (ID)'), ) + asn = django_filters.ModelMultipleChoiceFilter( + field_name='asns__asn', + queryset=ASN.objects.all(), + to_field_name='asn', + label=_('ASN'), + ) class Meta: model = Provider - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -95,7 +101,7 @@ class ProviderAccountFilterSet(NetBoxModelFilterSet): class Meta: model = ProviderAccount - fields = ['id', 'name', 'account', 'description'] + fields = ('id', 'name', 'account', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -122,7 +128,7 @@ class ProviderNetworkFilterSet(NetBoxModelFilterSet): class Meta: model = ProviderNetwork - fields = ['id', 'name', 'service_id', 'description'] + fields = ('id', 'name', 'service_id', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -139,7 +145,7 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet): class Meta: model = CircuitType - fields = ['id', 'name', 'slug', 'color', 'description'] + fields = ('id', 'name', 'slug', 'color', 'description') class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): @@ -158,6 +164,12 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte queryset=ProviderAccount.objects.all(), label=_('Provider account (ID)'), ) + provider_account = django_filters.ModelMultipleChoiceFilter( + field_name='provider_account__account', + queryset=Provider.objects.all(), + to_field_name='account', + label=_('Provider account (account)'), + ) provider_network_id = django_filters.ModelMultipleChoiceFilter( field_name='terminations__provider_network', queryset=ProviderNetwork.objects.all(), @@ -214,10 +226,18 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte to_field_name='slug', label=_('Site (slug)'), ) + termination_a_id = django_filters.ModelMultipleChoiceFilter( + queryset=CircuitTermination.objects.all(), + label=_('Termination A (ID)'), + ) + termination_z_id = django_filters.ModelMultipleChoiceFilter( + queryset=CircuitTermination.objects.all(), + label=_('Termination A (ID)'), + ) class Meta: model = Circuit - fields = ['id', 'cid', 'description', 'install_date', 'termination_date', 'commit_rate'] + fields = ('id', 'cid', 'description', 'install_date', 'termination_date', 'commit_rate') def search(self, queryset, name, value): if not value.strip(): @@ -255,10 +275,24 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet): queryset=ProviderNetwork.objects.all(), label=_('ProviderNetwork (ID)'), ) + provider_id = django_filters.ModelMultipleChoiceFilter( + field_name='circuit__provider_id', + queryset=Provider.objects.all(), + label=_('Provider (ID)'), + ) + provider = django_filters.ModelMultipleChoiceFilter( + field_name='circuit__provider__slug', + queryset=Provider.objects.all(), + to_field_name='slug', + label=_('Provider (slug)'), + ) class Meta: model = CircuitTermination - fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description', 'cable_end'] + fields = ( + 'id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description', 'mark_connected', + 'pp_info', 'cable_end', + ) def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index 5c416bff9..ea15c3010 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -3,15 +3,18 @@ from django.utils.translation import gettext_lazy as _ from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices from circuits.models import * +from dcim.models import Site from ipam.models import ASN from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import add_blank_choice from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField -from utilities.forms.widgets import DatePicker, NumberWithOptions +from utilities.forms.rendering import FieldSet, TabbedGroups +from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, NumberWithOptions __all__ = ( 'CircuitBulkEditForm', + 'CircuitTerminationBulkEditForm', 'CircuitTypeBulkEditForm', 'ProviderBulkEditForm', 'ProviderAccountBulkEditForm', @@ -34,7 +37,7 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm): model = Provider fieldsets = ( - (None, ('asns', 'description')), + FieldSet('asns', 'description'), ) nullable_fields = ( 'asns', 'description', 'comments', @@ -56,7 +59,7 @@ class ProviderAccountBulkEditForm(NetBoxModelBulkEditForm): model = ProviderAccount fieldsets = ( - (None, ('provider', 'description')), + FieldSet('provider', 'description'), ) nullable_fields = ( 'description', 'comments', @@ -83,7 +86,7 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm): model = ProviderNetwork fieldsets = ( - (None, ('provider', 'service_id', 'description')), + FieldSet('provider', 'service_id', 'description'), ) nullable_fields = ( 'service_id', 'description', 'comments', @@ -103,7 +106,7 @@ class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm): model = CircuitType fieldsets = ( - (None, ('color', 'description')), + FieldSet('color', 'description'), ) nullable_fields = ('color', 'description') @@ -164,10 +167,55 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm): model = Circuit fieldsets = ( - (_('Circuit'), ('provider', 'type', 'status', 'description')), - (_('Service Parameters'), ('provider_account', 'install_date', 'termination_date', 'commit_rate')), - (_('Tenancy'), ('tenant',)), + FieldSet('provider', 'type', 'status', 'description', name=_('Circuit')), + FieldSet('provider_account', 'install_date', 'termination_date', 'commit_rate', name=_('Service Parameters')), + FieldSet('tenant', name=_('Tenancy')), ) nullable_fields = ( 'tenant', 'commit_rate', 'description', 'comments', ) + + +class CircuitTerminationBulkEditForm(NetBoxModelBulkEditForm): + description = forms.CharField( + label=_('Description'), + max_length=200, + required=False + ) + site = DynamicModelChoiceField( + label=_('Site'), + queryset=Site.objects.all(), + required=False + ) + provider_network = DynamicModelChoiceField( + label=_('Provider Network'), + queryset=ProviderNetwork.objects.all(), + required=False + ) + port_speed = forms.IntegerField( + required=False, + label=_('Port speed (Kbps)'), + ) + upstream_speed = forms.IntegerField( + required=False, + label=_('Upstream speed (Kbps)'), + ) + mark_connected = forms.NullBooleanField( + label=_('Mark connected'), + required=False, + widget=BulkEditNullBooleanSelect + ) + + model = CircuitTermination + fieldsets = ( + FieldSet( + 'description', + TabbedGroups( + FieldSet('site', name=_('Site')), + FieldSet('provider_network', name=_('Provider Network')), + ), + 'mark_connected', name=_('Circuit Termination') + ), + FieldSet('port_speed', 'upstream_speed', name=_('Termination Details')), + ) + nullable_fields = ('description') diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index 0c30e3cda..1ceb44b60 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -1,18 +1,18 @@ from django import forms - -from circuits.choices import CircuitStatusChoices -from circuits.models import * -from dcim.models import Site from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ + +from circuits.choices import * +from circuits.models import * +from dcim.models import Site from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant -from utilities.forms import BootstrapMixin from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField __all__ = ( 'CircuitImportForm', 'CircuitTerminationImportForm', + 'CircuitTerminationImportRelatedForm', 'CircuitTypeImportForm', 'ProviderImportForm', 'ProviderAccountImportForm', @@ -112,7 +112,16 @@ class CircuitImportForm(NetBoxModelImportForm): ] -class CircuitTerminationImportForm(BootstrapMixin, forms.ModelForm): +class BaseCircuitTerminationImportForm(forms.ModelForm): + circuit = CSVModelChoiceField( + label=_('Circuit'), + queryset=Circuit.objects.all(), + to_field_name='cid', + ) + term_side = CSVChoiceField( + label=_('Termination'), + choices=CircuitTerminationSideChoices, + ) site = CSVModelChoiceField( label=_('Site'), queryset=Site.objects.all(), @@ -126,9 +135,21 @@ class CircuitTerminationImportForm(BootstrapMixin, forms.ModelForm): required=False ) + +class CircuitTerminationImportRelatedForm(BaseCircuitTerminationImportForm): class Meta: model = CircuitTermination fields = [ 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', - 'pp_info', 'description', + 'pp_info', 'description' + ] + + +class CircuitTerminationImportForm(NetBoxModelImportForm, BaseCircuitTerminationImportForm): + + class Meta: + model = CircuitTermination + fields = [ + 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', + 'pp_info', 'description', 'tags' ] diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index 1e1abd068..6f6473c3d 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -1,17 +1,19 @@ from django import forms from django.utils.translation import gettext as _ -from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices +from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices, CircuitTerminationSideChoices from circuits.models import * from dcim.models import Region, Site, SiteGroup from ipam.models import ASN from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm, ContactModelFilterForm from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField +from utilities.forms.rendering import FieldSet from utilities.forms.widgets import DatePicker, NumberWithOptions __all__ = ( 'CircuitFilterForm', + 'CircuitTerminationFilterForm', 'CircuitTypeFilterForm', 'ProviderFilterForm', 'ProviderAccountFilterForm', @@ -22,10 +24,10 @@ __all__ = ( class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Provider fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Location'), ('region_id', 'site_group_id', 'site_id')), - (_('ASN'), ('asn',)), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), + FieldSet('asn_id', name=_('ASN')), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -46,10 +48,6 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): }, label=_('Site') ) - asn = forms.IntegerField( - required=False, - label=_('ASN (legacy)') - ) asn_id = DynamicModelMultipleChoiceField( queryset=ASN.objects.all(), required=False, @@ -61,8 +59,8 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class ProviderAccountFilterForm(NetBoxModelFilterSetForm): model = ProviderAccount fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('provider_id', 'account')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('provider_id', 'account', name=_('Attributes')), ) provider_id = DynamicModelMultipleChoiceField( queryset=Provider.objects.all(), @@ -79,8 +77,8 @@ class ProviderAccountFilterForm(NetBoxModelFilterSetForm): class ProviderNetworkFilterForm(NetBoxModelFilterSetForm): model = ProviderNetwork fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('provider_id', 'service_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('provider_id', 'service_id', name=_('Attributes')), ) provider_id = DynamicModelMultipleChoiceField( queryset=Provider.objects.all(), @@ -98,8 +96,8 @@ class ProviderNetworkFilterForm(NetBoxModelFilterSetForm): class CircuitTypeFilterForm(NetBoxModelFilterSetForm): model = CircuitType fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('color',)), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('color', name=_('Attributes')), ) tag = TagFilterField(model) @@ -112,12 +110,12 @@ class CircuitTypeFilterForm(NetBoxModelFilterSetForm): class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Circuit fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Provider'), ('provider_id', 'provider_account_id', 'provider_network_id')), - (_('Attributes'), ('type_id', 'status', 'install_date', 'termination_date', 'commit_rate')), - (_('Location'), ('region_id', 'site_group_id', 'site_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')), + FieldSet('type_id', 'status', 'install_date', 'termination_date', 'commit_rate', name=_('Attributes')), + FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), ) selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'provider_id', 'provider_network_id') type_id = DynamicModelMultipleChoiceField( @@ -189,3 +187,46 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi ) ) tag = TagFilterField(model) + + +class CircuitTerminationFilterForm(NetBoxModelFilterSetForm): + model = CircuitTermination + fieldsets = ( + FieldSet('q', 'filter_id', 'tag'), + FieldSet('circuit_id', 'term_side', name=_('Circuit')), + FieldSet('provider_id', 'provider_network_id', name=_('Provider')), + FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region_id', + 'site_group_id': '$site_group_id', + }, + label=_('Site') + ) + circuit_id = DynamicModelMultipleChoiceField( + queryset=Circuit.objects.all(), + required=False, + label=_('Circuit') + ) + term_side = forms.MultipleChoiceField( + label=_('Term Side'), + choices=CircuitTerminationSideChoices, + required=False + ) + provider_network_id = DynamicModelMultipleChoiceField( + queryset=ProviderNetwork.objects.all(), + required=False, + query_params={ + 'provider_id': '$provider_id' + }, + label=_('Provider network') + ) + provider_id = DynamicModelMultipleChoiceField( + queryset=Provider.objects.all(), + required=False, + label=_('Provider') + ) + tag = TagFilterField(model) diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py index 0809cb2f4..ee5e47ce7 100644 --- a/netbox/circuits/forms/model_forms.py +++ b/netbox/circuits/forms/model_forms.py @@ -7,6 +7,7 @@ from ipam.models import ASN from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField +from utilities.forms.rendering import FieldSet, TabbedGroups from utilities.forms.widgets import DatePicker, NumberWithOptions __all__ = ( @@ -29,7 +30,7 @@ class ProviderForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Provider'), ('name', 'slug', 'asns', 'description', 'tags')), + FieldSet('name', 'slug', 'asns', 'description', 'tags'), ) class Meta: @@ -61,7 +62,7 @@ class ProviderNetworkForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Provider Network'), ('provider', 'name', 'service_id', 'description', 'tags')), + FieldSet('provider', 'name', 'service_id', 'description', 'tags'), ) class Meta: @@ -75,9 +76,7 @@ class CircuitTypeForm(NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Circuit Type'), ( - 'name', 'slug', 'color', 'description', 'tags', - )), + FieldSet('name', 'slug', 'color', 'description', 'tags'), ) class Meta: @@ -107,9 +106,9 @@ class CircuitForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Circuit'), ('provider', 'provider_account', 'cid', 'type', 'status', 'description', 'tags')), - (_('Service Parameters'), ('install_date', 'termination_date', 'commit_rate')), - (_('Tenancy'), ('tenant_group', 'tenant')), + FieldSet('provider', 'provider_account', 'cid', 'type', 'status', 'description', 'tags', name=_('Circuit')), + FieldSet('install_date', 'termination_date', 'commit_rate', name=_('Service Parameters')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) class Meta: @@ -146,6 +145,18 @@ class CircuitTerminationForm(NetBoxModelForm): selector=True ) + fieldsets = ( + FieldSet( + 'circuit', 'term_side', 'description', 'tags', + TabbedGroups( + FieldSet('site', name=_('Site')), + FieldSet('provider_network', name=_('Provider Network')), + ), + 'mark_connected', name=_('Circuit Termination') + ), + FieldSet('port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', name=_('Termination Details')), + ) + class Meta: model = CircuitTermination fields = [ diff --git a/netbox/circuits/graphql/filters.py b/netbox/circuits/graphql/filters.py new file mode 100644 index 000000000..10887ce3f --- /dev/null +++ b/netbox/circuits/graphql/filters.py @@ -0,0 +1,50 @@ +import strawberry +import strawberry_django +from circuits import filtersets, models + +from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin + +__all__ = ( + 'CircuitTerminationFilter', + 'CircuitFilter', + 'CircuitTypeFilter', + 'ProviderFilter', + 'ProviderAccountFilter', + 'ProviderNetworkFilter', +) + + +@strawberry_django.filter(models.CircuitTermination, lookups=True) +@autotype_decorator(filtersets.CircuitTerminationFilterSet) +class CircuitTerminationFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.Circuit, lookups=True) +@autotype_decorator(filtersets.CircuitFilterSet) +class CircuitFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.CircuitType, lookups=True) +@autotype_decorator(filtersets.CircuitTypeFilterSet) +class CircuitTypeFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.Provider, lookups=True) +@autotype_decorator(filtersets.ProviderFilterSet) +class ProviderFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.ProviderAccount, lookups=True) +@autotype_decorator(filtersets.ProviderAccountFilterSet) +class ProviderAccountFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.ProviderNetwork, lookups=True) +@autotype_decorator(filtersets.ProviderNetworkFilterSet) +class ProviderNetworkFilter(BaseFilterMixin): + pass diff --git a/netbox/circuits/graphql/schema.py b/netbox/circuits/graphql/schema.py index 3d85f2512..ac8626cc5 100644 --- a/netbox/circuits/graphql/schema.py +++ b/netbox/circuits/graphql/schema.py @@ -1,41 +1,40 @@ -import graphene +from typing import List + +import strawberry +import strawberry_django from circuits import models -from netbox.graphql.fields import ObjectField, ObjectListField from .types import * -from utilities.graphql_optimizer import gql_query_optimizer -class CircuitsQuery(graphene.ObjectType): - circuit = ObjectField(CircuitType) - circuit_list = ObjectListField(CircuitType) +@strawberry.type +class CircuitsQuery: + @strawberry.field + def circuit(self, id: int) -> CircuitType: + return models.Circuit.objects.get(pk=id) + circuit_list: List[CircuitType] = strawberry_django.field() - def resolve_circuit_list(root, info, **kwargs): - return gql_query_optimizer(models.Circuit.objects.all(), info) + @strawberry.field + def circuit_termination(self, id: int) -> CircuitTerminationType: + return models.CircuitTermination.objects.get(pk=id) + circuit_termination_list: List[CircuitTerminationType] = strawberry_django.field() - circuit_termination = ObjectField(CircuitTerminationType) - circuit_termination_list = ObjectListField(CircuitTerminationType) + @strawberry.field + def circuit_type(self, id: int) -> CircuitTypeType: + return models.CircuitType.objects.get(pk=id) + circuit_type_list: List[CircuitTypeType] = strawberry_django.field() - def resolve_circuit_termination_list(root, info, **kwargs): - return gql_query_optimizer(models.CircuitTermination.objects.all(), info) + @strawberry.field + def provider(self, id: int) -> ProviderType: + return models.Provider.objects.get(pk=id) + provider_list: List[ProviderType] = strawberry_django.field() - circuit_type = ObjectField(CircuitTypeType) - circuit_type_list = ObjectListField(CircuitTypeType) + @strawberry.field + def provider_account(self, id: int) -> ProviderAccountType: + return models.ProviderAccount.objects.get(pk=id) + provider_account_list: List[ProviderAccountType] = strawberry_django.field() - def resolve_circuit_type_list(root, info, **kwargs): - return gql_query_optimizer(models.CircuitType.objects.all(), info) - - provider = ObjectField(ProviderType) - provider_list = ObjectListField(ProviderType) - - def resolve_provider_list(root, info, **kwargs): - return gql_query_optimizer(models.Provider.objects.all(), info) - - provider_account = ObjectField(ProviderAccountType) - provider_account_list = ObjectListField(ProviderAccountType) - - provider_network = ObjectField(ProviderNetworkType) - provider_network_list = ObjectListField(ProviderNetworkType) - - def resolve_provider_network_list(root, info, **kwargs): - return gql_query_optimizer(models.ProviderNetwork.objects.all(), info) + @strawberry.field + def provider_network(self, id: int) -> ProviderNetworkType: + return models.ProviderNetwork.objects.get(pk=id) + provider_network_list: List[ProviderNetworkType] = strawberry_django.field() diff --git a/netbox/circuits/graphql/types.py b/netbox/circuits/graphql/types.py index baa135e00..bae91e6b0 100644 --- a/netbox/circuits/graphql/types.py +++ b/netbox/circuits/graphql/types.py @@ -1,9 +1,14 @@ -import graphene +from typing import Annotated, List -from circuits import filtersets, models +import strawberry +import strawberry_django + +from circuits import models from dcim.graphql.mixins import CabledObjectMixin -from extras.graphql.mixins import CustomFieldsMixin, TagsMixin, ContactsMixin -from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType +from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin +from netbox.graphql.types import NetBoxObjectType, ObjectType, OrganizationalObjectType +from tenancy.graphql.types import TenantType +from .filters import * __all__ = ( 'CircuitTerminationType', @@ -15,48 +20,74 @@ __all__ = ( ) -class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType): - - class Meta: - model = models.CircuitTermination - fields = '__all__' - filterset_class = filtersets.CircuitTerminationFilterSet - - -class CircuitType(NetBoxObjectType, ContactsMixin): - class Meta: - model = models.Circuit - fields = '__all__' - filterset_class = filtersets.CircuitFilterSet - - -class CircuitTypeType(OrganizationalObjectType): - - class Meta: - model = models.CircuitType - fields = '__all__' - filterset_class = filtersets.CircuitTypeFilterSet - - +@strawberry_django.type( + models.Provider, + fields='__all__', + filters=ProviderFilter +) class ProviderType(NetBoxObjectType, ContactsMixin): - class Meta: - model = models.Provider - fields = '__all__' - filterset_class = filtersets.ProviderFilterSet + networks: List[Annotated["ProviderNetworkType", strawberry.lazy('circuits.graphql.types')]] + circuits: List[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]] + asns: List[Annotated["ASNType", strawberry.lazy('ipam.graphql.types')]] + accounts: List[Annotated["ProviderAccountType", strawberry.lazy('circuits.graphql.types')]] +@strawberry_django.type( + models.ProviderAccount, + fields='__all__', + filters=ProviderAccountFilter +) class ProviderAccountType(NetBoxObjectType): + provider: Annotated["ProviderType", strawberry.lazy('circuits.graphql.types')] - class Meta: - model = models.ProviderAccount - fields = '__all__' - filterset_class = filtersets.ProviderAccountFilterSet + circuits: List[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]] +@strawberry_django.type( + models.ProviderNetwork, + fields='__all__', + filters=ProviderNetworkFilter +) class ProviderNetworkType(NetBoxObjectType): + provider: Annotated["ProviderType", strawberry.lazy('circuits.graphql.types')] - class Meta: - model = models.ProviderNetwork - fields = '__all__' - filterset_class = filtersets.ProviderNetworkFilterSet + circuit_terminations: List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]] + + +@strawberry_django.type( + models.CircuitTermination, + fields='__all__', + filters=CircuitTerminationFilter +) +class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType): + circuit: Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')] + provider_network: Annotated["ProviderNetworkType", strawberry.lazy('circuits.graphql.types')] | None + site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] | None + + +@strawberry_django.type( + models.CircuitType, + fields='__all__', + filters=CircuitTypeFilter +) +class CircuitTypeType(OrganizationalObjectType): + color: str + + circuits: List[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]] + + +@strawberry_django.type( + models.Circuit, + fields='__all__', + filters=CircuitFilter +) +class CircuitType(NetBoxObjectType, ContactsMixin): + provider: ProviderType + provider_account: ProviderAccountType | None + termination_a: CircuitTerminationType | None + termination_z: CircuitTerminationType | None + type: CircuitTypeType + tenant: TenantType | None + + terminations: List[CircuitTerminationType] diff --git a/netbox/circuits/migrations/0003_extend_tag_support.py b/netbox/circuits/migrations/0003_extend_tag_support.py deleted file mode 100644 index e5e6ee262..000000000 --- a/netbox/circuits/migrations/0003_extend_tag_support.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 3.2.8 on 2021-10-21 14:50 - -from django.db import migrations -import taggit.managers - - -class Migration(migrations.Migration): - - dependencies = [ - ('extras', '0062_clear_secrets_changelog'), - ('circuits', '0002_squashed_0029'), - ] - - operations = [ - migrations.AddField( - model_name='circuittype', - name='tags', - field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), - ), - ] diff --git a/netbox/circuits/migrations/0003_squashed_0037.py b/netbox/circuits/migrations/0003_squashed_0037.py new file mode 100644 index 000000000..69c3e1c68 --- /dev/null +++ b/netbox/circuits/migrations/0003_squashed_0037.py @@ -0,0 +1,127 @@ +import taggit.managers +from django.db import migrations, models + +import utilities.json + + +class Migration(migrations.Migration): + + replaces = [ + ('circuits', '0003_extend_tag_support'), + ('circuits', '0004_rename_cable_peer'), + ('circuits', '0032_provider_service_id'), + ('circuits', '0033_standardize_id_fields'), + ('circuits', '0034_created_datetimefield'), + ('circuits', '0035_provider_asns'), + ('circuits', '0036_circuit_termination_date_tags_custom_fields'), + ('circuits', '0037_new_cabling_models') + ] + + dependencies = [ + ('ipam', '0047_squashed_0053'), + ('extras', '0002_squashed_0059'), + ('circuits', '0002_squashed_0029'), + ] + + operations = [ + migrations.AddField( + model_name='circuittype', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.RenameField( + model_name='circuittermination', + old_name='_cable_peer_id', + new_name='_link_peer_id', + ), + migrations.RenameField( + model_name='circuittermination', + old_name='_cable_peer_type', + new_name='_link_peer_type', + ), + migrations.AddField( + model_name='providernetwork', + name='service_id', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AlterField( + model_name='circuit', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='circuittermination', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='circuittype', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='provider', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='providernetwork', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='circuittermination', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='circuit', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='circuittermination', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='circuittype', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='provider', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='providernetwork', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='provider', + name='asns', + field=models.ManyToManyField(blank=True, related_name='providers', to='ipam.asn'), + ), + migrations.AddField( + model_name='circuit', + name='termination_date', + field=models.DateField(blank=True, null=True), + ), + migrations.AddField( + model_name='circuittermination', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), + ), + migrations.AddField( + model_name='circuittermination', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='circuittermination', + name='cable_end', + field=models.CharField(blank=True, max_length=1), + ), + ] diff --git a/netbox/circuits/migrations/0004_rename_cable_peer.py b/netbox/circuits/migrations/0004_rename_cable_peer.py deleted file mode 100644 index 81d507eb4..000000000 --- a/netbox/circuits/migrations/0004_rename_cable_peer.py +++ /dev/null @@ -1,21 +0,0 @@ -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('circuits', '0003_extend_tag_support'), - ] - - operations = [ - migrations.RenameField( - model_name='circuittermination', - old_name='_cable_peer_id', - new_name='_link_peer_id', - ), - migrations.RenameField( - model_name='circuittermination', - old_name='_cable_peer_type', - new_name='_link_peer_type', - ), - ] diff --git a/netbox/circuits/migrations/0032_provider_service_id.py b/netbox/circuits/migrations/0032_provider_service_id.py deleted file mode 100644 index 58936d1bd..000000000 --- a/netbox/circuits/migrations/0032_provider_service_id.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('circuits', '0004_rename_cable_peer'), - ('dcim', '0145_site_remove_deprecated_fields'), - ] - - operations = [ - migrations.AddField( - model_name='providernetwork', - name='service_id', - field=models.CharField(blank=True, max_length=100), - ), - ] diff --git a/netbox/circuits/migrations/0033_standardize_id_fields.py b/netbox/circuits/migrations/0033_standardize_id_fields.py deleted file mode 100644 index 475fc2527..000000000 --- a/netbox/circuits/migrations/0033_standardize_id_fields.py +++ /dev/null @@ -1,44 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('circuits', '0032_provider_service_id'), - ] - - operations = [ - # Model IDs - migrations.AlterField( - model_name='circuit', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='circuittermination', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='circuittype', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='provider', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='providernetwork', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - - # GFK IDs - migrations.AlterField( - model_name='circuittermination', - name='_link_peer_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - ] diff --git a/netbox/circuits/migrations/0034_created_datetimefield.py b/netbox/circuits/migrations/0034_created_datetimefield.py deleted file mode 100644 index 4af78c1a2..000000000 --- a/netbox/circuits/migrations/0034_created_datetimefield.py +++ /dev/null @@ -1,38 +0,0 @@ -# Generated by Django 4.0.2 on 2022-02-08 18:54 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('circuits', '0033_standardize_id_fields'), - ] - - operations = [ - migrations.AlterField( - model_name='circuit', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='circuittermination', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='circuittype', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='provider', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='providernetwork', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - ] diff --git a/netbox/circuits/migrations/0035_provider_asns.py b/netbox/circuits/migrations/0035_provider_asns.py deleted file mode 100644 index afb0da4d6..000000000 --- a/netbox/circuits/migrations/0035_provider_asns.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 4.0.3 on 2022-03-30 20:27 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('ipam', '0057_created_datetimefield'), - ('circuits', '0034_created_datetimefield'), - ] - - operations = [ - migrations.AddField( - model_name='provider', - name='asns', - field=models.ManyToManyField(blank=True, related_name='providers', to='ipam.asn'), - ), - ] diff --git a/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py b/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py deleted file mode 100644 index 96b2a9d97..000000000 --- a/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py +++ /dev/null @@ -1,28 +0,0 @@ -from utilities.json import CustomFieldJSONEncoder -from django.db import migrations, models -import taggit.managers - - -class Migration(migrations.Migration): - - dependencies = [ - ('circuits', '0035_provider_asns'), - ] - - operations = [ - migrations.AddField( - model_name='circuit', - name='termination_date', - field=models.DateField(blank=True, null=True), - ), - migrations.AddField( - model_name='circuittermination', - name='custom_field_data', - field=models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder), - ), - migrations.AddField( - model_name='circuittermination', - name='tags', - field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), - ), - ] diff --git a/netbox/circuits/migrations/0037_new_cabling_models.py b/netbox/circuits/migrations/0037_new_cabling_models.py deleted file mode 100644 index ee08147f3..000000000 --- a/netbox/circuits/migrations/0037_new_cabling_models.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('circuits', '0036_circuit_termination_date_tags_custom_fields'), - ] - - operations = [ - migrations.AddField( - model_name='circuittermination', - name='cable_end', - field=models.CharField(blank=True, max_length=1), - ), - ] diff --git a/netbox/circuits/migrations/0038_cabling_cleanup.py b/netbox/circuits/migrations/0038_cabling_cleanup.py deleted file mode 100644 index 0672057e3..000000000 --- a/netbox/circuits/migrations/0038_cabling_cleanup.py +++ /dev/null @@ -1,20 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('circuits', '0037_new_cabling_models'), - ('dcim', '0160_populate_cable_ends'), - ] - - operations = [ - migrations.RemoveField( - model_name='circuittermination', - name='_link_peer_id', - ), - migrations.RemoveField( - model_name='circuittermination', - name='_link_peer_type', - ), - ] diff --git a/netbox/circuits/migrations/0042_provideraccount.py b/netbox/circuits/migrations/0038_squashed_0042.py similarity index 50% rename from netbox/circuits/migrations/0042_provideraccount.py rename to netbox/circuits/migrations/0038_squashed_0042.py index 3e583844e..f57fde3db 100644 --- a/netbox/circuits/migrations/0042_provideraccount.py +++ b/netbox/circuits/migrations/0038_squashed_0042.py @@ -1,46 +1,83 @@ -from django.db import migrations, models import django.db.models.deletion import taggit.managers +from django.db import migrations, models + import utilities.json -def create_provideraccounts_from_providers(apps, schema_editor): - """ - Migrate Account in Provider model to separate account model - """ - Provider = apps.get_model('circuits', 'Provider') - ProviderAccount = apps.get_model('circuits', 'ProviderAccount') - - provider_accounts = [] - for provider in Provider.objects.all(): - if provider.account: - provider_accounts.append(ProviderAccount( - provider=provider, - account=provider.account - )) - ProviderAccount.objects.bulk_create(provider_accounts, batch_size=100) - - -def restore_providers_from_provideraccounts(apps, schema_editor): - """ - Restore Provider account values from auto-generated ProviderAccounts - """ - ProviderAccount = apps.get_model('circuits', 'ProviderAccount') - provider_accounts = ProviderAccount.objects.order_by('pk') - for provideraccount in provider_accounts: - if provider_accounts.filter(provider=provideraccount.provider)[0] == provideraccount: - provideraccount.provider.account = provideraccount.account - provideraccount.provider.save() - - class Migration(migrations.Migration): - dependencies = [ - ('extras', '0084_staging'), + replaces = [ + ('circuits', '0038_cabling_cleanup'), + ('circuits', '0039_unique_constraints'), + ('circuits', '0040_provider_remove_deprecated_fields'), ('circuits', '0041_standardize_description_comments'), + ('circuits', '0042_provideraccount') + ] + + dependencies = [ + ('circuits', '0037_new_cabling_models'), + ('dcim', '0160_populate_cable_ends'), ] operations = [ + migrations.RemoveField( + model_name='circuittermination', + name='_link_peer_id', + ), + migrations.RemoveField( + model_name='circuittermination', + name='_link_peer_type', + ), + migrations.RemoveConstraint( + model_name='providernetwork', + name='circuits_providernetwork_provider_name', + ), + migrations.AlterUniqueTogether( + name='circuit', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='circuittermination', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='providernetwork', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='circuit', + constraint=models.UniqueConstraint(fields=('provider', 'cid'), name='circuits_circuit_unique_provider_cid'), + ), + migrations.AddConstraint( + model_name='circuittermination', + constraint=models.UniqueConstraint(fields=('circuit', 'term_side'), name='circuits_circuittermination_unique_circuit_term_side'), + ), + migrations.AddConstraint( + model_name='providernetwork', + constraint=models.UniqueConstraint(fields=('provider', 'name'), name='circuits_providernetwork_unique_provider_name'), + ), + migrations.RemoveField( + model_name='provider', + name='admin_contact', + ), + migrations.RemoveField( + model_name='provider', + name='asn', + ), + migrations.RemoveField( + model_name='provider', + name='noc_contact', + ), + migrations.RemoveField( + model_name='provider', + name='portal_url', + ), + migrations.AddField( + model_name='provider', + name='description', + field=models.CharField(blank=True, max_length=200), + ), migrations.CreateModel( name='ProviderAccount', fields=[ @@ -67,9 +104,6 @@ class Migration(migrations.Migration): model_name='provideraccount', constraint=models.UniqueConstraint(fields=('provider', 'account'), name='circuits_provideraccount_unique_provider_account'), ), - migrations.RunPython( - create_provideraccounts_from_providers, restore_providers_from_provideraccounts - ), migrations.RemoveField( model_name='provider', name='account', @@ -77,7 +111,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='circuit', name='provider_account', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='circuits.provideraccount', null=True, blank=True), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='circuits.provideraccount'), preserve_default=False, ), migrations.AlterModelOptions( diff --git a/netbox/circuits/migrations/0039_unique_constraints.py b/netbox/circuits/migrations/0039_unique_constraints.py deleted file mode 100644 index 1d5b62499..000000000 --- a/netbox/circuits/migrations/0039_unique_constraints.py +++ /dev/null @@ -1,39 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('circuits', '0038_cabling_cleanup'), - ] - - operations = [ - migrations.RemoveConstraint( - model_name='providernetwork', - name='circuits_providernetwork_provider_name', - ), - migrations.AlterUniqueTogether( - name='circuit', - unique_together=set(), - ), - migrations.AlterUniqueTogether( - name='circuittermination', - unique_together=set(), - ), - migrations.AlterUniqueTogether( - name='providernetwork', - unique_together=set(), - ), - migrations.AddConstraint( - model_name='circuit', - constraint=models.UniqueConstraint(fields=('provider', 'cid'), name='circuits_circuit_unique_provider_cid'), - ), - migrations.AddConstraint( - model_name='circuittermination', - constraint=models.UniqueConstraint(fields=('circuit', 'term_side'), name='circuits_circuittermination_unique_circuit_term_side'), - ), - migrations.AddConstraint( - model_name='providernetwork', - constraint=models.UniqueConstraint(fields=('provider', 'name'), name='circuits_providernetwork_unique_provider_name'), - ), - ] diff --git a/netbox/circuits/migrations/0040_provider_remove_deprecated_fields.py b/netbox/circuits/migrations/0040_provider_remove_deprecated_fields.py deleted file mode 100644 index 98c82204d..000000000 --- a/netbox/circuits/migrations/0040_provider_remove_deprecated_fields.py +++ /dev/null @@ -1,59 +0,0 @@ -import os - -from django.db import migrations -from django.db.utils import DataError - - -def check_legacy_data(apps, schema_editor): - """ - Abort the migration if any legacy provider fields still contain data. - """ - Provider = apps.get_model('circuits', 'Provider') - - provider_count = Provider.objects.exclude(asn__isnull=True).count() - if provider_count and 'NETBOX_DELETE_LEGACY_DATA' not in os.environ: - raise DataError( - f"Unable to proceed with deleting asn field from Provider model: Found {provider_count} " - f"providers with legacy ASN data. Please ensure all legacy provider ASN data has been " - f"migrated to ASN objects before proceeding. Or, set the NETBOX_DELETE_LEGACY_DATA " - f"environment variable to bypass this safeguard and delete all legacy provider ASN data." - ) - - provider_count = Provider.objects.exclude(admin_contact='', noc_contact='', portal_url='').count() - if provider_count and 'NETBOX_DELETE_LEGACY_DATA' not in os.environ: - raise DataError( - f"Unable to proceed with deleting contact fields from Provider model: Found {provider_count} " - f"providers with legacy contact data. Please ensure all legacy provider contact data has been " - f"migrated to contact objects before proceeding. Or, set the NETBOX_DELETE_LEGACY_DATA " - f"environment variable to bypass this safeguard and delete all legacy provider contact data." - ) - - -class Migration(migrations.Migration): - - dependencies = [ - ('circuits', '0039_unique_constraints'), - ] - - operations = [ - migrations.RunPython( - code=check_legacy_data, - reverse_code=migrations.RunPython.noop - ), - migrations.RemoveField( - model_name='provider', - name='admin_contact', - ), - migrations.RemoveField( - model_name='provider', - name='asn', - ), - migrations.RemoveField( - model_name='provider', - name='noc_contact', - ), - migrations.RemoveField( - model_name='provider', - name='portal_url', - ), - ] diff --git a/netbox/circuits/migrations/0041_standardize_description_comments.py b/netbox/circuits/migrations/0041_standardize_description_comments.py deleted file mode 100644 index 49cdefcba..000000000 --- a/netbox/circuits/migrations/0041_standardize_description_comments.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.1.2 on 2022-11-03 18:24 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('circuits', '0040_provider_remove_deprecated_fields'), - ] - - operations = [ - migrations.AddField( - model_name='provider', - name='description', - field=models.CharField(blank=True, max_length=200), - ), - ] diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 7b65d52ad..fa21d7cd3 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -227,7 +227,7 @@ class CircuitTermination( return f'{self.circuit}: Termination {self.term_side}' def get_absolute_url(self): - return self.circuit.get_absolute_url() + return reverse('circuits:circuittermination', args=[self.pk]) def clean(self): super().clean() diff --git a/netbox/circuits/search.py b/netbox/circuits/search.py index c22b400eb..f3fa359ba 100644 --- a/netbox/circuits/search.py +++ b/netbox/circuits/search.py @@ -48,6 +48,7 @@ class ProviderIndex(SearchIndex): display_attrs = ('description',) +@register_search class ProviderAccountIndex(SearchIndex): model = models.ProviderAccount fields = ( diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py index 6ae727eca..5d650df61 100644 --- a/netbox/circuits/tables/circuits.py +++ b/netbox/circuits/tables/circuits.py @@ -10,6 +10,7 @@ from .columns import CommitRateColumn __all__ = ( 'CircuitTable', + 'CircuitTerminationTable', 'CircuitTypeTable', ) @@ -88,3 +89,31 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): default_columns = ( 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description', ) + + +class CircuitTerminationTable(NetBoxTable): + circuit = tables.Column( + verbose_name=_('Circuit'), + linkify=True + ) + provider = tables.Column( + verbose_name=_('Provider'), + linkify=True, + accessor='circuit.provider' + ) + site = tables.Column( + verbose_name=_('Site'), + linkify=True + ) + provider_network = tables.Column( + verbose_name=_('Provider Network'), + linkify=True + ) + + class Meta(NetBoxTable.Meta): + model = CircuitTermination + fields = ( + 'pk', 'id', 'circuit', 'provider', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', + 'xconnect_id', 'pp_info', 'description', 'created', 'last_updated', 'actions', + ) + default_columns = ('pk', 'id', 'circuit', 'provider', 'term_side', 'description') diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 1969441eb..d3745f2b1 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -18,7 +18,7 @@ class AppTest(APITestCase): class ProviderTest(APIViewTestCases.APIViewTestCase): model = Provider - brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url'] + brief_fields = ['circuit_count', 'description', 'display', 'id', 'name', 'slug', 'url'] bulk_update_data = { 'comments': 'New comments', } @@ -60,7 +60,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase): class CircuitTypeTest(APIViewTestCases.APIViewTestCase): model = CircuitType - brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url'] + brief_fields = ['circuit_count', 'description', 'display', 'id', 'name', 'slug', 'url'] create_data = ( { 'name': 'Circuit Type 4', @@ -92,7 +92,7 @@ class CircuitTypeTest(APIViewTestCases.APIViewTestCase): class CircuitTest(APIViewTestCases.APIViewTestCase): model = Circuit - brief_fields = ['cid', 'display', 'id', 'url'] + brief_fields = ['cid', 'description', 'display', 'id', 'url'] bulk_update_data = { 'status': 'planned', } @@ -141,7 +141,7 @@ class CircuitTest(APIViewTestCases.APIViewTestCase): { 'cid': 'Circuit 6', 'provider': providers[1].pk, - 'provider_account': provider_accounts[1].pk, + # Omit provider account to test uniqueness constraint 'type': circuit_types[1].pk, }, ] @@ -149,7 +149,7 @@ class CircuitTest(APIViewTestCases.APIViewTestCase): class CircuitTerminationTest(APIViewTestCases.APIViewTestCase): model = CircuitTermination - brief_fields = ['_occupied', 'cable', 'circuit', 'display', 'id', 'term_side', 'url'] + brief_fields = ['_occupied', 'cable', 'circuit', 'description', 'display', 'id', 'term_side', 'url'] @classmethod def setUpTestData(cls): @@ -208,7 +208,7 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase): class ProviderAccountTest(APIViewTestCases.APIViewTestCase): model = ProviderAccount - brief_fields = ['account', 'display', 'id', 'name', 'url'] + brief_fields = ['account', 'description', 'display', 'id', 'name', 'url'] @classmethod def setUpTestData(cls): @@ -237,7 +237,7 @@ class ProviderAccountTest(APIViewTestCases.APIViewTestCase): 'account': '5678', }, { - 'name': 'Provider Account 6', + # Omit name to test uniqueness constraint 'provider': providers[0].pk, 'account': '6789', }, @@ -251,7 +251,7 @@ class ProviderAccountTest(APIViewTestCases.APIViewTestCase): class ProviderNetworkTest(APIViewTestCases.APIViewTestCase): model = ProviderNetwork - brief_fields = ['display', 'id', 'name', 'url'] + brief_fields = ['description', 'display', 'id', 'name', 'url'] @classmethod def setUpTestData(cls): diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index 6553179ec..df10c3929 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -90,10 +90,12 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_asn_id(self): # ASN object assignment + def test_asn(self): asns = ASN.objects.all()[:2] params = {'asn_id': [asns[0].pk, asns[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'asn': [asns[0].asn, asns[1].asn]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_region(self): regions = Region.objects.all()[:2] @@ -330,6 +332,7 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests): class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = CircuitTermination.objects.all() filterset = CircuitTerminationFilterSet + ignore_fields = ('cable',) @classmethod def setUpTestData(cls): @@ -348,24 +351,26 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): providers = ( Provider(name='Provider 1', slug='provider-1'), + Provider(name='Provider 2', slug='provider-2'), + Provider(name='Provider 3', slug='provider-3'), ) Provider.objects.bulk_create(providers) provider_networks = ( ProviderNetwork(name='Provider Network 1', provider=providers[0]), - ProviderNetwork(name='Provider Network 2', provider=providers[0]), - ProviderNetwork(name='Provider Network 3', provider=providers[0]), + ProviderNetwork(name='Provider Network 2', provider=providers[1]), + ProviderNetwork(name='Provider Network 3', provider=providers[2]), ) ProviderNetwork.objects.bulk_create(provider_networks) circuits = ( Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 1'), - Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 2'), - Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 3'), + Circuit(provider=providers[1], type=circuit_types[0], cid='Circuit 2'), + Circuit(provider=providers[2], type=circuit_types[0], cid='Circuit 3'), Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 4'), - Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 5'), - Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 6'), - Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 7'), + Circuit(provider=providers[1], type=circuit_types[0], cid='Circuit 5'), + Circuit(provider=providers[2], type=circuit_types[0], cid='Circuit 6'), + Circuit(provider=providers[2], type=circuit_types[0], cid='Circuit 7'), ) Circuit.objects.bulk_create(circuits) @@ -410,10 +415,17 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_circuit_id(self): - circuits = Circuit.objects.all()[:2] + circuits = Circuit.objects.filter(cid__in=['Circuit 1', 'Circuit 2']) params = {'circuit_id': [circuits[0].pk, circuits[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_provider(self): + providers = Provider.objects.all()[:2] + params = {'provider_id': [providers[0].pk, providers[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'provider': [providers[0].slug, providers[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 85e2304cf..577548703 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -5,8 +5,11 @@ from django.urls import reverse from circuits.choices import * from circuits.models import * +from core.models import ObjectType from dcim.models import Cable, Interface, Site from ipam.models import ASN, RIR +from netbox.choices import ImportFormatChoices +from users.models import ObjectPermission from utilities.testing import ViewTestCases, create_tags, create_test_device @@ -115,6 +118,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): @classmethod def setUpTestData(cls): + Site.objects.create(name='Site 1', slug='site-1') providers = ( Provider(name='Provider 1', slug='provider-1'), @@ -184,6 +188,51 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'comments': 'New comments', } + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[]) + def test_bulk_import_objects_with_terminations(self): + json_data = """ + [ + { + "cid": "Circuit 7", + "provider": "Provider 1", + "type": "Circuit Type 1", + "status": "active", + "description": "Testing Import", + "terminations": [ + { + "term_side": "A", + "site": "Site 1" + }, + { + "term_side": "Z", + "site": "Site 1" + } + ] + } + ] + """ + initial_count = self._get_queryset().count() + data = { + 'data': json_data, + 'format': ImportFormatChoices.JSON, + } + + # Assign model-level permission + obj_perm = ObjectPermission( + name='Test permission', + actions=['add'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) + + # Try GET with model-level permission + self.assertHttpStatus(self.client.get(self._get_url('import')), 200) + + # Test POST with permission + self.assertHttpStatus(self.client.post(self._get_url('import'), data), 302) + self.assertEqual(self._get_queryset().count(), initial_count + 1) + class ProviderAccountTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = ProviderAccount @@ -287,10 +336,7 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase): } -class CircuitTerminationTestCase( - ViewTestCases.EditObjectViewTestCase, - ViewTestCases.DeleteObjectViewTestCase, -): +class CircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = CircuitTermination @classmethod @@ -327,6 +373,24 @@ class CircuitTerminationTestCase( 'description': 'New description', } + cls.csv_data = ( + "circuit,term_side,site,description", + "Circuit 3,A,Site 1,Foo", + "Circuit 3,Z,Site 1,Bar", + ) + + cls.csv_update_data = ( + "id,port_speed,description", + f"{circuit_terminations[0].pk},100,New description7", + f"{circuit_terminations[1].pk},200,New description8", + f"{circuit_terminations[2].pk},300,New description9", + ) + + cls.bulk_edit_data = { + 'port_speed': 400, + 'description': 'New description', + } + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_trace(self): device = create_test_device('Device 1') diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 55a192c64..5c0ab99ee 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -48,7 +48,11 @@ urlpatterns = [ path('circuits//', include(get_model_urls('circuits', 'circuit'))), # Circuit terminations + path('circuit-terminations/', views.CircuitTerminationListView.as_view(), name='circuittermination_list'), path('circuit-terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'), + path('circuit-terminations/import/', views.CircuitTerminationBulkImportView.as_view(), name='circuittermination_import'), + path('circuit-terminations/edit/', views.CircuitTerminationBulkEditView.as_view(), name='circuittermination_bulk_edit'), + path('circuit-terminations/delete/', views.CircuitTerminationBulkDeleteView.as_view(), name='circuittermination_bulk_delete'), path('circuit-terminations//', include(get_model_urls('circuits', 'circuittermination'))), ] diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 64dd82682..def9a3640 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -6,7 +6,7 @@ from dcim.views import PathTraceView from netbox.views import generic from tenancy.views import ObjectContactsView from utilities.forms import ConfirmationForm -from utilities.utils import count_related +from utilities.query import count_related from utilities.views import register_model_view from . import filtersets, forms, tables from .models import * @@ -298,7 +298,7 @@ class CircuitBulkImportView(generic.BulkImportView): 'circuits.add_circuittermination', ] related_object_forms = { - 'terminations': forms.CircuitTerminationImportForm, + 'terminations': forms.CircuitTerminationImportRelatedForm, } def prep_related_object_data(self, parent, data): @@ -408,11 +408,22 @@ class CircuitContactsView(ObjectContactsView): # Circuit terminations # +class CircuitTerminationListView(generic.ObjectListView): + queryset = CircuitTermination.objects.all() + filterset = filtersets.CircuitTerminationFilterSet + filterset_form = forms.CircuitTerminationFilterForm + table = tables.CircuitTerminationTable + + +@register_model_view(CircuitTermination) +class CircuitTerminationView(generic.ObjectView): + queryset = CircuitTermination.objects.all() + + @register_model_view(CircuitTermination, 'edit') class CircuitTerminationEditView(generic.ObjectEditView): queryset = CircuitTermination.objects.all() form = forms.CircuitTerminationForm - template_name = 'circuits/circuittermination_edit.html' @register_model_view(CircuitTermination, 'delete') @@ -420,5 +431,23 @@ class CircuitTerminationDeleteView(generic.ObjectDeleteView): queryset = CircuitTermination.objects.all() +class CircuitTerminationBulkImportView(generic.BulkImportView): + queryset = CircuitTermination.objects.all() + model_form = forms.CircuitTerminationImportForm + + +class CircuitTerminationBulkEditView(generic.BulkEditView): + queryset = CircuitTermination.objects.all() + filterset = filtersets.CircuitTerminationFilterSet + table = tables.CircuitTerminationTable + form = forms.CircuitTerminationBulkEditForm + + +class CircuitTerminationBulkDeleteView(generic.BulkDeleteView): + queryset = CircuitTermination.objects.all() + filterset = filtersets.CircuitTerminationFilterSet + table = tables.CircuitTerminationTable + + # Trace view register_model_view(CircuitTermination, 'trace', kwargs={'model': CircuitTermination})(PathTraceView) diff --git a/netbox/core/api/nested_serializers.py b/netbox/core/api/nested_serializers.py index d99738cbe..efb748ee0 100644 --- a/netbox/core/api/nested_serializers.py +++ b/netbox/core/api/nested_serializers.py @@ -4,7 +4,7 @@ from core.choices import JobStatusChoices from core.models import * from netbox.api.fields import ChoiceField from netbox.api.serializers import WritableNestedSerializer -from users.api.nested_serializers import NestedUserSerializer +from users.api.serializers import UserSerializer __all__ = ( 'NestedDataFileSerializer', @@ -32,7 +32,8 @@ class NestedDataFileSerializer(WritableNestedSerializer): class NestedJobSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='core-api:job-detail') status = ChoiceField(choices=JobStatusChoices) - user = NestedUserSerializer( + user = UserSerializer( + nested=True, read_only=True ) diff --git a/netbox/core/api/schema.py b/netbox/core/api/schema.py index 8eecfa8b9..bcc49d3fc 100644 --- a/netbox/core/api/schema.py +++ b/netbox/core/api/schema.py @@ -156,8 +156,6 @@ class NetBoxAutoSchema(AutoSchema): remove_fields.append(child_name) if isinstance(child, (ChoiceField, WritableNestedSerializer)): properties[child_name] = None - elif isinstance(child, ManyRelatedField) and isinstance(child.child_relation, SerializedPKRelatedField): - properties[child_name] = None if not properties: return None @@ -257,3 +255,14 @@ class NetBoxAutoSchema(AutoSchema): if '{id}' in self.path: return f"{self.method.capitalize()} a {model_name} object." return f"{self.method.capitalize()} a list of {model_name} objects." + + +class FixSerializedPKRelatedField(OpenApiSerializerFieldExtension): + target_class = 'netbox.api.fields.SerializedPKRelatedField' + + def map_serializer_field(self, auto_schema, direction): + if direction == "response": + component = auto_schema.resolve_serializer(self.target.serializer, direction) + return component.ref if component else None + else: + return build_basic_type(OpenApiTypes.INT) diff --git a/netbox/core/api/serializers.py b/netbox/core/api/serializers.py index a16a06d62..8553bb91c 100644 --- a/netbox/core/api/serializers.py +++ b/netbox/core/api/serializers.py @@ -1,73 +1,3 @@ -from rest_framework import serializers - -from core.choices import * -from core.models import * -from netbox.api.fields import ChoiceField, ContentTypeField -from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer -from netbox.utils import get_data_backend_choices -from users.api.nested_serializers import NestedUserSerializer +from .serializers_.data import * +from .serializers_.jobs import * from .nested_serializers import * - -__all__ = ( - 'DataFileSerializer', - 'DataSourceSerializer', - 'JobSerializer', -) - - -class DataSourceSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField( - view_name='core-api:datasource-detail' - ) - type = ChoiceField( - choices=get_data_backend_choices() - ) - status = ChoiceField( - choices=DataSourceStatusChoices, - read_only=True - ) - - # Related object counts - file_count = serializers.IntegerField( - read_only=True - ) - - class Meta: - model = DataSource - fields = [ - 'id', 'url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description', 'comments', - 'parameters', 'ignore_rules', 'custom_fields', 'created', 'last_updated', 'file_count', - ] - - -class DataFileSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField( - view_name='core-api:datafile-detail' - ) - source = NestedDataSourceSerializer( - read_only=True - ) - - class Meta: - model = DataFile - fields = [ - 'id', 'url', 'display', 'source', 'path', 'last_updated', 'size', 'hash', - ] - - -class JobSerializer(BaseModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='core-api:job-detail') - user = NestedUserSerializer( - read_only=True - ) - status = ChoiceField(choices=JobStatusChoices, read_only=True) - object_type = ContentTypeField( - read_only=True - ) - - class Meta: - model = Job - fields = [ - 'id', 'url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval', - 'started', 'completed', 'user', 'data', 'error', 'job_id', - ] diff --git a/netbox/core/api/serializers_/__init__.py b/netbox/core/api/serializers_/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/core/api/serializers_/data.py b/netbox/core/api/serializers_/data.py new file mode 100644 index 000000000..e60d8d95b --- /dev/null +++ b/netbox/core/api/serializers_/data.py @@ -0,0 +1,53 @@ +from rest_framework import serializers + +from core.choices import * +from core.models import DataFile, DataSource +from netbox.api.fields import ChoiceField, RelatedObjectCountField +from netbox.api.serializers import NetBoxModelSerializer +from netbox.utils import get_data_backend_choices + +__all__ = ( + 'DataFileSerializer', + 'DataSourceSerializer', +) + + +class DataSourceSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='core-api:datasource-detail' + ) + type = ChoiceField( + choices=get_data_backend_choices() + ) + status = ChoiceField( + choices=DataSourceStatusChoices, + read_only=True + ) + + # Related object counts + file_count = RelatedObjectCountField('datafiles') + + class Meta: + model = DataSource + fields = [ + 'id', 'url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description', 'comments', + 'parameters', 'ignore_rules', 'custom_fields', 'created', 'last_updated', 'file_count', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') + + +class DataFileSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='core-api:datafile-detail' + ) + source = DataSourceSerializer( + nested=True, + read_only=True + ) + + class Meta: + model = DataFile + fields = [ + 'id', 'url', 'display', 'source', 'path', 'last_updated', 'size', 'hash', + ] + brief_fields = ('id', 'url', 'display', 'path') diff --git a/netbox/core/api/serializers_/jobs.py b/netbox/core/api/serializers_/jobs.py new file mode 100644 index 000000000..84afa3627 --- /dev/null +++ b/netbox/core/api/serializers_/jobs.py @@ -0,0 +1,31 @@ +from rest_framework import serializers + +from core.choices import * +from core.models import Job +from netbox.api.fields import ChoiceField, ContentTypeField +from netbox.api.serializers import BaseModelSerializer +from users.api.serializers_.users import UserSerializer + +__all__ = ( + 'JobSerializer', +) + + +class JobSerializer(BaseModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='core-api:job-detail') + user = UserSerializer( + nested=True, + read_only=True + ) + status = ChoiceField(choices=JobStatusChoices, read_only=True) + object_type = ContentTypeField( + read_only=True + ) + + class Meta: + model = Job + fields = [ + 'id', 'url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval', + 'started', 'completed', 'user', 'data', 'error', 'job_id', + ] + brief_fields = ('url', 'created', 'completed', 'user', 'status') diff --git a/netbox/core/api/views.py b/netbox/core/api/views.py index 7bf2f87a6..6338523d2 100644 --- a/netbox/core/api/views.py +++ b/netbox/core/api/views.py @@ -1,5 +1,5 @@ from django.shortcuts import get_object_or_404 - +from django.utils.translation import gettext_lazy as _ from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response @@ -9,7 +9,6 @@ from rest_framework.viewsets import ReadOnlyModelViewSet from core import filtersets from core.models import * from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet -from utilities.utils import count_related from . import serializers @@ -22,9 +21,7 @@ class CoreRootView(APIRootView): class DataSourceViewSet(NetBoxModelViewSet): - queryset = DataSource.objects.annotate( - file_count=count_related(DataFile, 'source') - ) + queryset = DataSource.objects.all() serializer_class = serializers.DataSourceSerializer filterset_class = filtersets.DataSourceFilterSet @@ -33,10 +30,11 @@ class DataSourceViewSet(NetBoxModelViewSet): """ Enqueue a job to synchronize the DataSource. """ - if not request.user.has_perm('core.sync_datasource'): - raise PermissionDenied("Syncing data sources requires the core.sync_datasource permission.") - datasource = get_object_or_404(DataSource, pk=pk) + + if not request.user.has_perm('core.sync_datasource', obj=datasource): + raise PermissionDenied(_("This user does not have permission to synchronize this data source.")) + datasource.enqueue_sync_job(request) serializer = serializers.DataSourceSerializer(datasource, context={'request': request}) @@ -44,7 +42,7 @@ class DataSourceViewSet(NetBoxModelViewSet): class DataFileViewSet(NetBoxReadOnlyModelViewSet): - queryset = DataFile.objects.defer('data').prefetch_related('source') + queryset = DataFile.objects.defer('data') serializer_class = serializers.DataFileSerializer filterset_class = filtersets.DataFileFilterSet @@ -53,6 +51,6 @@ class JobViewSet(ReadOnlyModelViewSet): """ Retrieve a list of job results """ - queryset = Job.objects.prefetch_related('user') + queryset = Job.objects.all() serializer_class = serializers.JobSerializer filterset_class = filtersets.JobFilterSet diff --git a/netbox/core/apps.py b/netbox/core/apps.py index 2d999c57e..b1103469c 100644 --- a/netbox/core/apps.py +++ b/netbox/core/apps.py @@ -16,5 +16,9 @@ class CoreConfig(AppConfig): name = "core" def ready(self): + from core.api import schema # noqa + from netbox.models.features import register_models from . import data_backends, search - from core.api import schema # noqa: E402 + + # Register models + register_models(*self.get_models()) diff --git a/netbox/core/constants.py b/netbox/core/constants.py new file mode 100644 index 000000000..3c3382dcc --- /dev/null +++ b/netbox/core/constants.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass + +from django.utils.translation import gettext_lazy as _ +from rq.job import JobStatus + +__all__ = ( + 'RQ_TASK_STATUSES', +) + + +@dataclass +class Status: + label: str + color: str + + +RQ_TASK_STATUSES = { + JobStatus.QUEUED: Status(_('Queued'), 'cyan'), + JobStatus.FINISHED: Status(_('Finished'), 'green'), + JobStatus.FAILED: Status(_('Failed'), 'red'), + JobStatus.STARTED: Status(_('Started'), 'blue'), + JobStatus.DEFERRED: Status(_('Deferred'), 'gray'), + JobStatus.SCHEDULED: Status(_('Scheduled'), 'purple'), + JobStatus.STOPPED: Status(_('Stopped'), 'orange'), + JobStatus.CANCELED: Status(_('Cancelled'), 'yellow'), +} diff --git a/netbox/core/data_backends.py b/netbox/core/data_backends.py index 15891a6f5..2d3a7d8c8 100644 --- a/netbox/core/data_backends.py +++ b/netbox/core/data_backends.py @@ -149,7 +149,8 @@ class S3Backend(DataBackend): region_name=self._region_name, aws_access_key_id=aws_access_key_id, aws_secret_access_key=aws_secret_access_key, - config=self.config + config=self.config, + endpoint_url=self._endpoint_url ) bucket = s3.Bucket(self._bucket_name) @@ -176,6 +177,11 @@ class S3Backend(DataBackend): url_path = urlparse(self.url).path.lstrip('/') return url_path.split('/')[0] + @property + def _endpoint_url(self): + url_path = urlparse(self.url) + return url_path._replace(params="", fragment="", query="", path="").geturl() + @property def _remote_path(self): url_path = urlparse(self.url).path.lstrip('/') diff --git a/netbox/core/filtersets.py b/netbox/core/filtersets.py index 902e240ee..c5d332b68 100644 --- a/netbox/core/filtersets.py +++ b/netbox/core/filtersets.py @@ -28,7 +28,7 @@ class DataSourceFilterSet(NetBoxModelFilterSet): class Meta: model = DataSource - fields = ('id', 'name', 'enabled', 'description') + fields = ('id', 'name', 'enabled', 'description', 'source_url', 'last_synced') def search(self, queryset, name, value): if not value.strip(): @@ -115,7 +115,7 @@ class JobFilterSet(BaseFilterSet): class Meta: model = Job - fields = ('id', 'object_type', 'object_id', 'name', 'interval', 'status', 'user') + fields = ('id', 'object_type', 'object_id', 'name', 'interval', 'status', 'user', 'job_id') def search(self, queryset, name, value): if not value.strip(): @@ -134,9 +134,7 @@ class ConfigRevisionFilterSet(BaseFilterSet): class Meta: model = ConfigRevision - fields = [ - 'id', - ] + fields = ('id', 'created', 'comment') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/core/forms/bulk_edit.py b/netbox/core/forms/bulk_edit.py index bc2ef8fc9..c1f1fca4d 100644 --- a/netbox/core/forms/bulk_edit.py +++ b/netbox/core/forms/bulk_edit.py @@ -5,6 +5,7 @@ from core.models import * from netbox.forms import NetBoxModelBulkEditForm from netbox.utils import get_data_backend_choices from utilities.forms.fields import CommentField +from utilities.forms.rendering import FieldSet from utilities.forms.widgets import BulkEditNullBooleanSelect __all__ = ( @@ -41,7 +42,7 @@ class DataSourceBulkEditForm(NetBoxModelBulkEditForm): model = DataSource fieldsets = ( - (None, ('type', 'enabled', 'description', 'comments', 'parameters', 'ignore_rules')), + FieldSet('type', 'enabled', 'description', 'comments', 'parameters', 'ignore_rules'), ) nullable_fields = ( 'description', 'description', 'parameters', 'comments', 'parameters', 'ignore_rules', diff --git a/netbox/core/forms/filtersets.py b/netbox/core/forms/filtersets.py index f21bd3f87..60a3acc44 100644 --- a/netbox/core/forms/filtersets.py +++ b/netbox/core/forms/filtersets.py @@ -9,7 +9,8 @@ from netbox.forms.mixins import SavedFiltersMixin from netbox.utils import get_data_backend_choices from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm from utilities.forms.fields import ContentTypeChoiceField, DynamicModelMultipleChoiceField -from utilities.forms.widgets import APISelectMultiple, DateTimePicker +from utilities.forms.rendering import FieldSet +from utilities.forms.widgets import DateTimePicker __all__ = ( 'ConfigRevisionFilterForm', @@ -22,8 +23,8 @@ __all__ = ( class DataSourceFilterForm(NetBoxModelFilterSetForm): model = DataSource fieldsets = ( - (None, ('q', 'filter_id')), - (_('Data Source'), ('type', 'status')), + FieldSet('q', 'filter_id'), + FieldSet('type', 'status', name=_('Data Source')), ) type = forms.MultipleChoiceField( label=_('Type'), @@ -47,8 +48,8 @@ class DataSourceFilterForm(NetBoxModelFilterSetForm): class DataFileFilterForm(NetBoxModelFilterSetForm): model = DataFile fieldsets = ( - (None, ('q', 'filter_id')), - (_('File'), ('source_id',)), + FieldSet('q', 'filter_id'), + FieldSet('source_id', name=_('File')), ) source_id = DynamicModelMultipleChoiceField( queryset=DataSource.objects.all(), @@ -59,16 +60,16 @@ class DataFileFilterForm(NetBoxModelFilterSetForm): class JobFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'filter_id')), - (_('Attributes'), ('object_type', 'status')), - (_('Creation'), ( + FieldSet('q', 'filter_id'), + FieldSet('object_type', 'status', name=_('Attributes')), + FieldSet( 'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before', - 'started__after', 'completed__before', 'completed__after', 'user', - )), + 'started__after', 'completed__before', 'completed__after', 'user', name=_('Creation') + ), ) object_type = ContentTypeChoiceField( label=_('Object Type'), - queryset=ContentType.objects.with_feature('jobs'), + queryset=ObjectType.objects.with_feature('jobs'), required=False, ) status = forms.MultipleChoiceField( @@ -119,14 +120,11 @@ class JobFilterForm(SavedFiltersMixin, FilterForm): user = DynamicModelMultipleChoiceField( queryset=get_user_model().objects.all(), required=False, - label=_('User'), - widget=APISelectMultiple( - api_url='/api/users/users/', - ) + label=_('User') ) class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - (None, ('q', 'filter_id')), + FieldSet('q', 'filter_id'), ) diff --git a/netbox/core/forms/model_forms.py b/netbox/core/forms/model_forms.py index ae891dd59..a05377597 100644 --- a/netbox/core/forms/model_forms.py +++ b/netbox/core/forms/model_forms.py @@ -3,6 +3,7 @@ import json from django import forms from django.conf import settings +from django.forms.fields import JSONField as _JSONField from django.utils.translation import gettext_lazy as _ from core.forms.mixins import SyncedDataMixin @@ -11,8 +12,9 @@ from netbox.config import get_config, PARAMS from netbox.forms import NetBoxModelForm from netbox.registry import registry from netbox.utils import get_data_backend_choices -from utilities.forms import BootstrapMixin, get_field_value -from utilities.forms.fields import CommentField +from utilities.forms import get_field_value +from utilities.forms.fields import CommentField, JSONField +from utilities.forms.rendering import FieldSet from utilities.forms.widgets import HTMXSelect __all__ = ( @@ -49,11 +51,11 @@ class DataSourceForm(NetBoxModelForm): @property def fieldsets(self): fieldsets = [ - (_('Source'), ('name', 'type', 'source_url', 'enabled', 'description', 'tags', 'ignore_rules')), + FieldSet('name', 'type', 'source_url', 'enabled', 'description', 'tags', 'ignore_rules', name=_('Source')), ] if self.backend_fields: fieldsets.append( - (_('Backend Parameters'), self.backend_fields) + FieldSet(*self.backend_fields, name=_('Backend Parameters')) ) return fieldsets @@ -91,8 +93,8 @@ class ManagedFileForm(SyncedDataMixin, NetBoxModelForm): ) fieldsets = ( - (_('File Upload'), ('upload_file',)), - (_('Data Source'), ('data_source', 'data_file', 'auto_sync_enabled')), + FieldSet('upload_file', name=_('File Upload')), + FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')), ) class Meta: @@ -132,30 +134,39 @@ class ConfigFormMetaclass(forms.models.ModelFormMetaclass): 'help_text': param.description, } field_kwargs.update(**param.field_kwargs) + if param.field is _JSONField: + # Replace with our own JSONField to get pretty JSON in config editor + param.field = JSONField param_fields[param.name] = param.field(**field_kwargs) attrs.update(param_fields) return super().__new__(mcs, name, bases, attrs) -class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMetaclass): +class ConfigRevisionForm(forms.ModelForm, metaclass=ConfigFormMetaclass): """ Form for creating a new ConfigRevision. """ fieldsets = ( - (_('Rack Elevations'), ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH')), - (_('Power'), ('POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION')), - (_('IPAM'), ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4')), - (_('Security'), ('ALLOWED_URL_SCHEMES',)), - (_('Banners'), ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM')), - (_('Pagination'), ('PAGINATE_COUNT', 'MAX_PAGE_SIZE')), - (_('Validation'), ('CUSTOM_VALIDATORS', 'PROTECTION_RULES')), - (_('User Preferences'), ('DEFAULT_USER_PREFERENCES',)), - (_('Miscellaneous'), ( + FieldSet( + 'RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH', name=_('Rack Elevations') + ), + FieldSet( + 'POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION', + name=_('Power') + ), + FieldSet('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4', name=_('IPAM')), + FieldSet('ALLOWED_URL_SCHEMES', name=_('Security')), + FieldSet('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM', name=_('Banners')), + FieldSet('PAGINATE_COUNT', 'MAX_PAGE_SIZE', name=_('Pagination')), + FieldSet('CUSTOM_VALIDATORS', 'PROTECTION_RULES', name=_('Validation')), + FieldSet('DEFAULT_USER_PREFERENCES', name=_('User Preferences')), + FieldSet( 'MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL', - )), - (_('Config Revision'), ('comment',)) + name=_('Miscellaneous') + ), + FieldSet('comment', name=_('Config Revision')) ) class Meta: diff --git a/netbox/core/graphql/filters.py b/netbox/core/graphql/filters.py new file mode 100644 index 000000000..64b4d0de2 --- /dev/null +++ b/netbox/core/graphql/filters.py @@ -0,0 +1,21 @@ +import strawberry_django + +from core import filtersets, models +from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin + +__all__ = ( + 'DataFileFilter', + 'DataSourceFilter', +) + + +@strawberry_django.filter(models.DataFile, lookups=True) +@autotype_decorator(filtersets.DataFileFilterSet) +class DataFileFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.DataSource, lookups=True) +@autotype_decorator(filtersets.DataSourceFilterSet) +class DataSourceFilter(BaseFilterMixin): + pass diff --git a/netbox/core/graphql/schema.py b/netbox/core/graphql/schema.py index 876faa442..34135cd47 100644 --- a/netbox/core/graphql/schema.py +++ b/netbox/core/graphql/schema.py @@ -1,20 +1,20 @@ -import graphene +from typing import List + +import strawberry +import strawberry_django from core import models -from netbox.graphql.fields import ObjectField, ObjectListField from .types import * -from utilities.graphql_optimizer import gql_query_optimizer -class CoreQuery(graphene.ObjectType): - data_file = ObjectField(DataFileType) - data_file_list = ObjectListField(DataFileType) +@strawberry.type +class CoreQuery: + @strawberry.field + def data_file(self, id: int) -> DataFileType: + return models.DataFile.objects.get(pk=id) + data_file_list: List[DataFileType] = strawberry_django.field() - def resolve_data_file_list(root, info, **kwargs): - return gql_query_optimizer(models.DataFile.objects.all(), info) - - data_source = ObjectField(DataSourceType) - data_source_list = ObjectListField(DataSourceType) - - def resolve_data_source_list(root, info, **kwargs): - return gql_query_optimizer(models.DataSource.objects.all(), info) + @strawberry.field + def data_source(self, id: int) -> DataSourceType: + return models.DataSource.objects.get(pk=id) + data_source_list: List[DataSourceType] = strawberry_django.field() diff --git a/netbox/core/graphql/types.py b/netbox/core/graphql/types.py index 402e36345..8287bfa31 100644 --- a/netbox/core/graphql/types.py +++ b/netbox/core/graphql/types.py @@ -1,5 +1,11 @@ -from core import filtersets, models +from typing import Annotated, List + +import strawberry +import strawberry_django + +from core import models from netbox.graphql.types import BaseObjectType, NetBoxObjectType +from .filters import * __all__ = ( 'DataFileType', @@ -7,15 +13,20 @@ __all__ = ( ) +@strawberry_django.type( + models.DataFile, + exclude=['data',], + filters=DataFileFilter +) class DataFileType(BaseObjectType): - class Meta: - model = models.DataFile - exclude = ('data',) - filterset_class = filtersets.DataFileFilterSet + source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] +@strawberry_django.type( + models.DataSource, + fields='__all__', + filters=DataSourceFilter +) class DataSourceType(NetBoxObjectType): - class Meta: - model = models.DataSource - fields = '__all__' - filterset_class = filtersets.DataSourceFilterSet + + datafiles: List[Annotated["DataFileType", strawberry.lazy('core.graphql.types')]] diff --git a/netbox/core/management/commands/nbshell.py b/netbox/core/management/commands/nbshell.py index eeefe502b..b96870252 100644 --- a/netbox/core/management/commands/nbshell.py +++ b/netbox/core/management/commands/nbshell.py @@ -8,7 +8,7 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand -from core.models import ContentType +from core.models import ObjectType APPS = ('circuits', 'core', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization', 'vpn', 'wireless') @@ -60,7 +60,7 @@ class Command(BaseCommand): pass # Additional objects to include - namespace['ContentType'] = ContentType + namespace['ObjectType'] = ObjectType namespace['User'] = get_user_model() # Load convenience commands diff --git a/netbox/core/migrations/0001_initial.py b/netbox/core/migrations/0001_squashed_0005.py similarity index 51% rename from netbox/core/migrations/0001_initial.py rename to netbox/core/migrations/0001_squashed_0005.py index 775a5dcb1..971370bf2 100644 --- a/netbox/core/migrations/0001_initial.py +++ b/netbox/core/migrations/0001_squashed_0005.py @@ -1,18 +1,26 @@ -# Generated by Django 4.1.5 on 2023-02-02 02:37 - import django.core.validators -from django.db import migrations, models import django.db.models.deletion import taggit.managers +from django.conf import settings +from django.db import migrations, models + import utilities.json class Migration(migrations.Migration): - initial = True + replaces = [ + ('core', '0001_initial'), + ('core', '0002_managedfile'), + ('core', '0003_job'), + ('core', '0004_replicate_jobresults'), + ('core', '0005_job_created_auto_now') + ] dependencies = [ - ('extras', '0084_staging'), + ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('extras', '0002_squashed_0059'), ] operations = [ @@ -71,13 +79,61 @@ class Migration(migrations.Migration): ('datafile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='core.datafile')), ('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')), ], - ), - migrations.AddIndex( - model_name='autosyncrecord', - index=models.Index(fields=['object_type', 'object_id'], name='core_autosy_object__c17bac_idx'), + options={ + 'indexes': [models.Index(fields=['object_type', 'object_id'], name='core_autosy_object__c17bac_idx')], + }, ), migrations.AddConstraint( model_name='autosyncrecord', constraint=models.UniqueConstraint(fields=('object_type', 'object_id'), name='core_autosyncrecord_object'), ), + migrations.CreateModel( + name='ManagedFile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('data_path', models.CharField(blank=True, editable=False, max_length=1000)), + ('data_synced', models.DateTimeField(blank=True, editable=False, null=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('last_updated', models.DateTimeField(blank=True, editable=False, null=True)), + ('file_root', models.CharField(max_length=1000)), + ('file_path', models.FilePathField(editable=False)), + ('data_file', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.datafile')), + ('data_source', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource')), + ('auto_sync_enabled', models.BooleanField(default=False)), + ], + options={ + 'ordering': ('file_root', 'file_path'), + 'indexes': [models.Index(fields=['file_root', 'file_path'], name='core_managedfile_root_path')], + }, + ), + migrations.AddConstraint( + model_name='managedfile', + constraint=models.UniqueConstraint(fields=('file_root', 'file_path'), name='core_managedfile_unique_root_path'), + ), + migrations.CreateModel( + name='Job', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('object_id', models.PositiveBigIntegerField(blank=True, null=True)), + ('name', models.CharField(max_length=200)), + ('created', models.DateTimeField()), + ('scheduled', models.DateTimeField(blank=True, null=True)), + ('interval', models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)])), + ('started', models.DateTimeField(blank=True, null=True)), + ('completed', models.DateTimeField(blank=True, null=True)), + ('status', models.CharField(default='pending', max_length=30)), + ('data', models.JSONField(blank=True, null=True)), + ('job_id', models.UUIDField(unique=True)), + ('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='contenttypes.contenttype')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created'], + }, + ), + migrations.AlterField( + model_name='job', + name='created', + field=models.DateTimeField(auto_now_add=True), + ), ] diff --git a/netbox/core/migrations/0002_managedfile.py b/netbox/core/migrations/0002_managedfile.py deleted file mode 100644 index 169063be8..000000000 --- a/netbox/core/migrations/0002_managedfile.py +++ /dev/null @@ -1,40 +0,0 @@ -# Generated by Django 4.1.7 on 2023-03-23 17:35 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='ManagedFile', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), - ('data_path', models.CharField(blank=True, editable=False, max_length=1000)), - ('data_synced', models.DateTimeField(blank=True, editable=False, null=True)), - ('created', models.DateTimeField(auto_now_add=True)), - ('last_updated', models.DateTimeField(blank=True, editable=False, null=True)), - ('file_root', models.CharField(max_length=1000)), - ('file_path', models.FilePathField(editable=False)), - ('data_file', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.datafile')), - ('data_source', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource')), - ('auto_sync_enabled', models.BooleanField(default=False)), - ], - options={ - 'ordering': ('file_root', 'file_path'), - }, - ), - migrations.AddIndex( - model_name='managedfile', - index=models.Index(fields=['file_root', 'file_path'], name='core_managedfile_root_path'), - ), - migrations.AddConstraint( - model_name='managedfile', - constraint=models.UniqueConstraint(fields=('file_root', 'file_path'), name='core_managedfile_unique_root_path'), - ), - ] diff --git a/netbox/core/migrations/0003_job.py b/netbox/core/migrations/0003_job.py deleted file mode 100644 index f2fe41afb..000000000 --- a/netbox/core/migrations/0003_job.py +++ /dev/null @@ -1,39 +0,0 @@ -# Generated by Django 4.1.7 on 2023-03-27 15:02 - -from django.conf import settings -import django.core.validators -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('core', '0002_managedfile'), - ] - - operations = [ - migrations.CreateModel( - name='Job', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), - ('object_id', models.PositiveBigIntegerField(blank=True, null=True)), - ('name', models.CharField(max_length=200)), - ('created', models.DateTimeField()), - ('scheduled', models.DateTimeField(blank=True, null=True)), - ('interval', models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)])), - ('started', models.DateTimeField(blank=True, null=True)), - ('completed', models.DateTimeField(blank=True, null=True)), - ('status', models.CharField(default='pending', max_length=30)), - ('data', models.JSONField(blank=True, null=True)), - ('job_id', models.UUIDField(unique=True)), - ('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='contenttypes.contenttype')), - ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'ordering': ['-created'], - }, - ), - ] diff --git a/netbox/core/migrations/0004_replicate_jobresults.py b/netbox/core/migrations/0004_replicate_jobresults.py deleted file mode 100644 index 881506b9b..000000000 --- a/netbox/core/migrations/0004_replicate_jobresults.py +++ /dev/null @@ -1,46 +0,0 @@ -from django.db import migrations - - -def replicate_jobresults(apps, schema_editor): - """ - Replicate existing JobResults to the new Jobs table before deleting the old JobResults table. - """ - Job = apps.get_model('core', 'Job') - JobResult = apps.get_model('extras', 'JobResult') - - jobs = [] - for job_result in JobResult.objects.order_by('pk').iterator(chunk_size=100): - jobs.append( - Job( - object_type=job_result.obj_type, - name=job_result.name, - created=job_result.created, - scheduled=job_result.scheduled, - interval=job_result.interval, - started=job_result.started, - completed=job_result.completed, - user=job_result.user, - status=job_result.status, - data=job_result.data, - job_id=job_result.job_id, - ) - ) - if len(jobs) == 100: - Job.objects.bulk_create(jobs) - jobs = [] - if jobs: - Job.objects.bulk_create(jobs) - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0003_job'), - ] - - operations = [ - migrations.RunPython( - code=replicate_jobresults, - reverse_code=migrations.RunPython.noop - ), - ] diff --git a/netbox/core/migrations/0005_job_created_auto_now.py b/netbox/core/migrations/0005_job_created_auto_now.py deleted file mode 100644 index 12fd526ef..000000000 --- a/netbox/core/migrations/0005_job_created_auto_now.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.1.7 on 2023-03-27 17:28 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0004_replicate_jobresults'), - ] - - operations = [ - migrations.AlterField( - model_name='job', - name='created', - field=models.DateTimeField(auto_now_add=True), - ), - ] diff --git a/netbox/core/migrations/0008_contenttype_proxy.py b/netbox/core/migrations/0008_contenttype_proxy.py index ac11d906a..dee82a969 100644 --- a/netbox/core/migrations/0008_contenttype_proxy.py +++ b/netbox/core/migrations/0008_contenttype_proxy.py @@ -1,5 +1,3 @@ -# Generated by Django 4.2.6 on 2023-10-31 19:38 - import core.models.contenttypes from django.db import migrations @@ -13,7 +11,7 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='ContentType', + name='ObjectType', fields=[ ], options={ @@ -23,7 +21,7 @@ class Migration(migrations.Migration): }, bases=('contenttypes.contenttype',), managers=[ - ('objects', core.models.contenttypes.ContentTypeManager()), + ('objects', core.models.contenttypes.ObjectTypeManager()), ], ), ] diff --git a/netbox/core/models/contenttypes.py b/netbox/core/models/contenttypes.py index c98184c3d..b0301848f 100644 --- a/netbox/core/models/contenttypes.py +++ b/netbox/core/models/contenttypes.py @@ -1,15 +1,15 @@ -from django.contrib.contenttypes.models import ContentType as ContentType_, ContentTypeManager as ContentTypeManager_ +from django.contrib.contenttypes.models import ContentType, ContentTypeManager from django.db.models import Q from netbox.registry import registry __all__ = ( - 'ContentType', - 'ContentTypeManager', + 'ObjectType', + 'ObjectTypeManager', ) -class ContentTypeManager(ContentTypeManager_): +class ObjectTypeManager(ContentTypeManager): def public(self): """ @@ -40,11 +40,11 @@ class ContentTypeManager(ContentTypeManager_): return self.get_queryset().filter(q) -class ContentType(ContentType_): +class ObjectType(ContentType): """ Wrap Django's native ContentType model to use our custom manager. """ - objects = ContentTypeManager() + objects = ObjectTypeManager() class Meta: proxy = True diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py index 4ceb22ba9..48fa2ff71 100644 --- a/netbox/core/models/data.py +++ b/netbox/core/models/data.py @@ -1,3 +1,4 @@ +import hashlib import logging import os import yaml @@ -18,7 +19,6 @@ from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED from netbox.models import PrimaryModel from netbox.models.features import JobsMixin from netbox.registry import registry -from utilities.files import sha256_hash from utilities.querysets import RestrictedQuerySet from ..choices import * from ..exceptions import SyncError @@ -357,7 +357,8 @@ class DataFile(models.Model): has changed. """ file_path = os.path.join(source_root, self.path) - file_hash = sha256_hash(file_path).hexdigest() + with open(file_path, 'rb') as f: + file_hash = hashlib.sha256(f.read()).hexdigest() # Update instance file attributes & data if is_modified := file_hash != self.hash: diff --git a/netbox/core/models/files.py b/netbox/core/models/files.py index 5a321bdc3..7b626a441 100644 --- a/netbox/core/models/files.py +++ b/netbox/core/models/files.py @@ -89,6 +89,9 @@ class ManagedFile(SyncedDataMixin, models.Model): def clean(self): super().clean() + if self.data_file and not self.file_path: + self.file_path = os.path.basename(self.data_path) + # Ensure that the file root and path make a unique pair if self._meta.model.objects.filter(file_root=self.file_root, file_path=self.file_path).exclude(pk=self.pk).exists(): raise ValidationError( diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index 2e3425129..b9f0d0b91 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -11,7 +11,7 @@ from django.utils import timezone from django.utils.translation import gettext as _ from core.choices import JobStatusChoices -from core.models import ContentType +from core.models import ObjectType from core.signals import job_end, job_start from extras.constants import EVENT_JOB_END, EVENT_JOB_START from netbox.config import get_config @@ -130,7 +130,7 @@ class Job(models.Model): super().clean() # Validate the assigned object type - if self.object_type not in ContentType.objects.with_feature('jobs'): + if self.object_type not in ObjectType.objects.with_feature('jobs'): raise ValidationError( _("Jobs cannot be assigned to this object type ({type}).").format(type=self.object_type) ) @@ -210,7 +210,7 @@ class Job(models.Model): schedule_at: Schedule the job to be executed at the passed date and time interval: Recurrence interval (in minutes) """ - object_type = ContentType.objects.get_for_model(instance, for_concrete_model=False) + object_type = ObjectType.objects.get_for_model(instance, for_concrete_model=False) rq_queue_name = get_queue_for_model(object_type.model) queue = django_rq.get_queue(rq_queue_name) status = JobStatusChoices.STATUS_SCHEDULED if schedule_at else JobStatusChoices.STATUS_PENDING diff --git a/netbox/core/tables/__init__.py b/netbox/core/tables/__init__.py index 69f9d8a48..8f219afa4 100644 --- a/netbox/core/tables/__init__.py +++ b/netbox/core/tables/__init__.py @@ -1,3 +1,5 @@ from .config import * from .data import * from .jobs import * +from .tasks import * +from .plugins import * diff --git a/netbox/core/tables/columns.py b/netbox/core/tables/columns.py index 93f1e3901..f3d985bc3 100644 --- a/netbox/core/tables/columns.py +++ b/netbox/core/tables/columns.py @@ -1,9 +1,12 @@ import django_tables2 as tables +from django.utils.safestring import mark_safe +from core.constants import RQ_TASK_STATUSES from netbox.registry import registry __all__ = ( 'BackendTypeColumn', + 'RQJobStatusColumn', ) @@ -18,3 +21,16 @@ class BackendTypeColumn(tables.Column): def value(self, value): return value + + +class RQJobStatusColumn(tables.Column): + """ + Render a colored label for the status of an RQ job. + """ + def render(self, value): + status = RQ_TASK_STATUSES.get(value) + return mark_safe(f'{status.label}') + + def value(self, value): + status = RQ_TASK_STATUSES.get(value) + return status.label diff --git a/netbox/core/tables/plugins.py b/netbox/core/tables/plugins.py new file mode 100644 index 000000000..21e90cd6b --- /dev/null +++ b/netbox/core/tables/plugins.py @@ -0,0 +1,39 @@ +import django_tables2 as tables +from django.utils.translation import gettext_lazy as _ +from netbox.tables import BaseTable + +__all__ = ( + 'PluginTable', +) + + +class PluginTable(BaseTable): + name = tables.Column( + accessor=tables.A('verbose_name'), + verbose_name=_('Name') + ) + version = tables.Column( + verbose_name=_('Version') + ) + package = tables.Column( + accessor=tables.A('name'), + verbose_name=_('Package') + ) + author = tables.Column( + verbose_name=_('Author') + ) + author_email = tables.Column( + verbose_name=_('Author Email') + ) + description = tables.Column( + verbose_name=_('Description') + ) + + class Meta(BaseTable.Meta): + empty_text = _('No plugins found') + fields = ( + 'name', 'version', 'package', 'author', 'author_email', 'description', + ) + default_columns = ( + 'name', 'version', 'package', 'description', + ) diff --git a/netbox/core/tables/tasks.py b/netbox/core/tables/tasks.py new file mode 100644 index 000000000..f53e598b5 --- /dev/null +++ b/netbox/core/tables/tasks.py @@ -0,0 +1,134 @@ +import django_tables2 as tables +from django.utils.translation import gettext_lazy as _ +from django_tables2.utils import A + +from core.tables.columns import RQJobStatusColumn +from netbox.tables import BaseTable, columns + + +class BackgroundQueueTable(BaseTable): + name = tables.Column( + verbose_name=_("Name") + ) + jobs = tables.Column( + linkify=("core:background_task_list", [A("index"), "queued"]), + verbose_name=_("Queued") + ) + oldest_job_timestamp = tables.Column( + verbose_name=_("Oldest Task") + ) + started_jobs = tables.Column( + linkify=("core:background_task_list", [A("index"), "started"]), + verbose_name=_("Active") + ) + deferred_jobs = tables.Column( + linkify=("core:background_task_list", [A("index"), "deferred"]), + verbose_name=_("Deferred") + ) + finished_jobs = tables.Column( + linkify=("core:background_task_list", [A("index"), "finished"]), + verbose_name=_("Finished") + ) + failed_jobs = tables.Column( + linkify=("core:background_task_list", [A("index"), "failed"]), + verbose_name=_("Failed") + ) + scheduled_jobs = tables.Column( + linkify=("core:background_task_list", [A("index"), "scheduled"]), + verbose_name=_("Scheduled") + ) + workers = tables.Column( + linkify=("core:worker_list", [A("index")]), + verbose_name=_("Workers") + ) + host = tables.Column( + accessor="connection_kwargs__host", + verbose_name=_("Host") + ) + port = tables.Column( + accessor="connection_kwargs__port", + verbose_name=_("Port") + ) + db = tables.Column( + accessor="connection_kwargs__db", + verbose_name=_("DB") + ) + pid = tables.Column( + accessor="scheduler__pid", + verbose_name=_("Scheduler PID") + ) + + class Meta(BaseTable.Meta): + empty_text = _('No queues found') + fields = ( + 'name', 'jobs', 'oldest_job_timestamp', 'started_jobs', 'deferred_jobs', 'finished_jobs', 'failed_jobs', + 'scheduled_jobs', 'workers', 'host', 'port', 'db', 'pid', + ) + default_columns = ( + 'name', 'jobs', 'started_jobs', 'deferred_jobs', 'finished_jobs', 'failed_jobs', 'scheduled_jobs', + 'workers', + ) + + +class BackgroundTaskTable(BaseTable): + id = tables.Column( + linkify=("core:background_task", [A("id")]), + verbose_name=_("ID") + ) + created_at = columns.DateTimeColumn( + verbose_name=_("Created") + ) + enqueued_at = columns.DateTimeColumn( + verbose_name=_("Enqueued") + ) + ended_at = columns.DateTimeColumn( + verbose_name=_("Ended") + ) + status = RQJobStatusColumn( + verbose_name=_("Status"), + accessor='get_status' + ) + callable = tables.Column( + empty_values=(), + verbose_name=_("Callable") + ) + + class Meta(BaseTable.Meta): + empty_text = _('No tasks found') + fields = ( + 'id', 'created_at', 'enqueued_at', 'ended_at', 'status', 'callable', + ) + default_columns = ( + 'id', 'created_at', 'enqueued_at', 'ended_at', 'status', 'callable', + ) + + def render_callable(self, value, record): + try: + return record.func_name + except Exception as e: + return repr(e) + + +class WorkerTable(BaseTable): + name = tables.Column( + linkify=("core:worker", [A("name")]), + verbose_name=_("Name") + ) + state = tables.Column( + verbose_name=_("State") + ) + birth_date = columns.DateTimeColumn( + verbose_name=_("Birth") + ) + pid = tables.Column( + verbose_name=_("PID") + ) + + class Meta(BaseTable.Meta): + empty_text = _('No workers found') + fields = ( + 'name', 'state', 'birth_date', 'pid', + ) + default_columns = ( + 'name', 'state', 'birth_date', 'pid', + ) diff --git a/netbox/core/tests/test_api.py b/netbox/core/tests/test_api.py index cd25761f0..44db21bff 100644 --- a/netbox/core/tests/test_api.py +++ b/netbox/core/tests/test_api.py @@ -16,7 +16,7 @@ class AppTest(APITestCase): class DataSourceTest(APIViewTestCases.APIViewTestCase): model = DataSource - brief_fields = ['display', 'id', 'name', 'url'] + brief_fields = ['description', 'display', 'id', 'name', 'url'] bulk_update_data = { 'enabled': False, 'description': 'foo bar baz', diff --git a/netbox/core/tests/test_filtersets.py b/netbox/core/tests/test_filtersets.py index e6e52a8b3..aefb9eed0 100644 --- a/netbox/core/tests/test_filtersets.py +++ b/netbox/core/tests/test_filtersets.py @@ -1,8 +1,6 @@ -from datetime import datetime +from datetime import datetime, timezone from django.test import TestCase -from django.utils import timezone - from utilities.testing import ChangeLoggedFilterSetTests from ..choices import * from ..filtersets import * @@ -12,6 +10,7 @@ from ..models import * class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = DataSource.objects.all() filterset = DataSourceFilterSet + ignore_fields = ('ignore_rules', 'parameters') @classmethod def setUpTestData(cls): @@ -72,6 +71,7 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests): class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = DataFile.objects.all() filterset = DataFileFilterSet + ignore_fields = ('data',) @classmethod def setUpTestData(cls): diff --git a/netbox/core/tests/test_views.py b/netbox/core/tests/test_views.py index 16d07f376..b7a951a0f 100644 --- a/netbox/core/tests/test_views.py +++ b/netbox/core/tests/test_views.py @@ -1,6 +1,16 @@ -from django.utils import timezone +import logging +import uuid +from datetime import datetime -from utilities.testing import ViewTestCases, create_tags +from django.urls import reverse +from django.utils import timezone +from django_rq import get_queue +from django_rq.settings import QUEUES_MAP +from django_rq.workers import get_worker +from rq.job import Job as RQ_Job, JobStatus +from rq.registry import DeferredJobRegistry, FailedJobRegistry, FinishedJobRegistry, StartedJobRegistry + +from utilities.testing import TestCase, ViewTestCases, create_tags from ..models import * @@ -87,3 +97,211 @@ class DataFileTestCase( ), ) DataFile.objects.bulk_create(data_files) + + +class BackgroundTaskTestCase(TestCase): + user_permissions = () + + # Dummy worker functions + @staticmethod + def dummy_job_default(): + return "Job finished" + + @staticmethod + def dummy_job_high(): + return "Job finished" + + @staticmethod + def dummy_job_failing(): + raise Exception("Job failed") + + def setUp(self): + super().setUp() + self.user.is_staff = True + self.user.is_active = True + self.user.save() + + # Clear all queues prior to running each test + get_queue('default').connection.flushall() + get_queue('high').connection.flushall() + get_queue('low').connection.flushall() + + def test_background_queue_list(self): + url = reverse('core:background_queue_list') + + # Attempt to load view without permission + self.user.is_staff = 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.save() + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertIn('default', str(response.content)) + self.assertIn('high', str(response.content)) + self.assertIn('low', str(response.content)) + + def test_background_tasks_list_default(self): + queue = get_queue('default') + queue.enqueue(self.dummy_job_default) + queue_index = QUEUES_MAP['default'] + + response = self.client.get(reverse('core:background_task_list', args=[queue_index, 'queued'])) + self.assertEqual(response.status_code, 200) + self.assertIn('BackgroundTaskTestCase.dummy_job_default', str(response.content)) + + def test_background_tasks_list_high(self): + queue = get_queue('high') + queue.enqueue(self.dummy_job_high) + queue_index = QUEUES_MAP['high'] + + response = self.client.get(reverse('core:background_task_list', args=[queue_index, 'queued'])) + self.assertEqual(response.status_code, 200) + self.assertIn('BackgroundTaskTestCase.dummy_job_high', str(response.content)) + + def test_background_tasks_list_finished(self): + queue = get_queue('default') + job = queue.enqueue(self.dummy_job_default) + queue_index = QUEUES_MAP['default'] + + registry = FinishedJobRegistry(queue.name, queue.connection) + registry.add(job, 2) + response = self.client.get(reverse('core:background_task_list', args=[queue_index, 'finished'])) + self.assertEqual(response.status_code, 200) + self.assertIn('BackgroundTaskTestCase.dummy_job_default', str(response.content)) + + def test_background_tasks_list_failed(self): + queue = get_queue('default') + job = queue.enqueue(self.dummy_job_default) + queue_index = QUEUES_MAP['default'] + + registry = FailedJobRegistry(queue.name, queue.connection) + registry.add(job, 2) + response = self.client.get(reverse('core:background_task_list', args=[queue_index, 'failed'])) + self.assertEqual(response.status_code, 200) + self.assertIn('BackgroundTaskTestCase.dummy_job_default', str(response.content)) + + def test_background_tasks_scheduled(self): + queue = get_queue('default') + queue.enqueue_at(datetime.now(), self.dummy_job_default) + queue_index = QUEUES_MAP['default'] + + response = self.client.get(reverse('core:background_task_list', args=[queue_index, 'scheduled'])) + self.assertEqual(response.status_code, 200) + self.assertIn('BackgroundTaskTestCase.dummy_job_default', str(response.content)) + + def test_background_tasks_list_deferred(self): + queue = get_queue('default') + job = queue.enqueue(self.dummy_job_default) + queue_index = QUEUES_MAP['default'] + + registry = DeferredJobRegistry(queue.name, queue.connection) + registry.add(job, 2) + response = self.client.get(reverse('core:background_task_list', args=[queue_index, 'deferred'])) + self.assertEqual(response.status_code, 200) + self.assertIn('BackgroundTaskTestCase.dummy_job_default', str(response.content)) + + def test_background_task(self): + queue = get_queue('default') + job = queue.enqueue(self.dummy_job_default) + + response = self.client.get(reverse('core:background_task', args=[job.id])) + self.assertEqual(response.status_code, 200) + self.assertIn('Background Tasks', str(response.content)) + self.assertIn(str(job.id), str(response.content)) + self.assertIn('Callable', str(response.content)) + self.assertIn('Meta', str(response.content)) + self.assertIn('Keyword Arguments', str(response.content)) + + def test_background_task_delete(self): + queue = get_queue('default') + job = queue.enqueue(self.dummy_job_default) + + response = self.client.post(reverse('core:background_task_delete', args=[job.id]), {'confirm': True}) + self.assertEqual(response.status_code, 302) + self.assertFalse(RQ_Job.exists(job.id, connection=queue.connection)) + self.assertNotIn(job.id, queue.job_ids) + + def test_background_task_requeue(self): + queue = get_queue('default') + + # Enqueue & run a job that will fail + job = queue.enqueue(self.dummy_job_failing) + worker = get_worker('default') + worker.work(burst=True) + self.assertTrue(job.is_failed) + + # Re-enqueue the failed job and check that its status has been reset + response = self.client.get(reverse('core:background_task_requeue', args=[job.id])) + self.assertEqual(response.status_code, 302) + self.assertFalse(job.is_failed) + + def test_background_task_enqueue(self): + queue = get_queue('default') + + # Enqueue some jobs that each depends on its predecessor + job = previous_job = None + for _ in range(0, 3): + job = queue.enqueue(self.dummy_job_default, depends_on=previous_job) + previous_job = job + + # 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) + + # Force-enqueue the deferred job + response = self.client.get(reverse('core:background_task_enqueue', args=[job.id])) + self.assertEqual(response.status_code, 302) + + # Check that job's status is updated correctly + job = queue.fetch_job(job.id) + self.assertEqual(job.get_status(), JobStatus.QUEUED) + self.assertIsNotNone(job.enqueued_at) + + 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) + + self.assertEqual(job.get_status(), JobStatus.STARTED) + + # Stop those jobs using the view + started_job_registry = StartedJobRegistry(queue.name, connection=queue.connection) + self.assertEqual(len(started_job_registry), 1) + response = self.client.get(reverse('core:background_task_stop', args=[job.id])) + self.assertEqual(response.status_code, 302) + worker.monitor_work_horse(job, queue) # Sets the job as Failed and removes from Started + self.assertEqual(len(started_job_registry), 0) + + canceled_job_registry = FailedJobRegistry(queue.name, connection=queue.connection) + self.assertEqual(len(canceled_job_registry), 1) + self.assertIn(job.id, canceled_job_registry) + + def test_worker_list(self): + worker1 = get_worker('default', name=uuid.uuid4().hex) + worker1.register_birth() + + worker2 = get_worker('high') + worker2.register_birth() + + queue_index = QUEUES_MAP['default'] + response = self.client.get(reverse('core:worker_list', args=[queue_index])) + self.assertEqual(response.status_code, 200) + self.assertIn(str(worker1.name), str(response.content)) + self.assertNotIn(str(worker2.name), str(response.content)) + + def test_worker(self): + worker1 = get_worker('default', name=uuid.uuid4().hex) + worker1.register_birth() + + response = self.client.get(reverse('core:worker', args=[worker1.name])) + self.assertEqual(response.status_code, 200) + self.assertIn(str(worker1.name), str(response.content)) + self.assertIn('Birth', str(response.content)) + self.assertIn('Total working time', str(response.content)) diff --git a/netbox/core/urls.py b/netbox/core/urls.py index 77c0d3194..59eead615 100644 --- a/netbox/core/urls.py +++ b/netbox/core/urls.py @@ -25,6 +25,17 @@ urlpatterns = ( path('jobs//', views.JobView.as_view(), name='job'), path('jobs//delete/', views.JobDeleteView.as_view(), name='job_delete'), + # Background Tasks + path('background-queues/', views.BackgroundQueueListView.as_view(), name='background_queue_list'), + path('background-queues///', views.BackgroundTaskListView.as_view(), name='background_task_list'), + path('background-tasks//', views.BackgroundTaskView.as_view(), name='background_task'), + path('background-tasks//delete/', views.BackgroundTaskDeleteView.as_view(), name='background_task_delete'), + path('background-tasks//requeue/', views.BackgroundTaskRequeueView.as_view(), name='background_task_requeue'), + path('background-tasks//enqueue/', views.BackgroundTaskEnqueueView.as_view(), name='background_task_enqueue'), + path('background-tasks//stop/', views.BackgroundTaskStopView.as_view(), name='background_task_stop'), + path('background-workers//', views.WorkerListView.as_view(), name='worker_list'), + path('background-workers//', views.WorkerView.as_view(), name='worker'), + # Config revisions path('config-revisions/', views.ConfigRevisionListView.as_view(), name='configrevision_list'), path('config-revisions/add/', views.ConfigRevisionEditView.as_view(), name='configrevision_add'), @@ -32,7 +43,6 @@ urlpatterns = ( path('config-revisions//restore/', views.ConfigRevisionRestoreView.as_view(), name='configrevision_restore'), path('config-revisions//', include(get_model_urls('core', 'configrevision'))), - # Configuration - path('config/', views.ConfigView.as_view(), name='config'), - + # System + path('system/', views.SystemView.as_view(), name='system'), ) diff --git a/netbox/core/views.py b/netbox/core/views.py index 6c87087f2..5a65c5755 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -1,13 +1,37 @@ +import json +import platform + +from django import __version__ as DJANGO_VERSION +from django.apps import apps +from django.conf import settings from django.contrib import messages +from django.contrib.auth.mixins import UserPassesTestMixin from django.core.cache import cache -from django.http import HttpResponseForbidden +from django.db import connection, ProgrammingError +from django.http import HttpResponse, HttpResponseForbidden, Http404 from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ from django.views.generic import View +from django_rq.queues import get_connection, get_queue_by_index, get_redis_connection +from django_rq.settings import QUEUES_MAP, QUEUES_LIST +from django_rq.utils import get_jobs, get_statistics, stop_jobs +from rq import requeue_job +from rq.exceptions import NoSuchJobError +from rq.job import Job as RQ_Job, JobStatus as RQJobStatus +from rq.registry import ( + DeferredJobRegistry, FailedJobRegistry, FinishedJobRegistry, ScheduledJobRegistry, StartedJobRegistry, +) +from rq.worker import Worker +from rq.worker_registration import clean_worker_registry from netbox.config import get_config, PARAMS from netbox.views import generic from netbox.views.generic.base import BaseObjectView -from utilities.utils import count_related +from netbox.views.generic.mixins import TableMixin +from utilities.forms import ConfirmationForm +from utilities.htmx import htmx_partial +from utilities.query import count_related from utilities.views import ContentTypePermissionRequiredMixin, register_model_view from . import filtersets, forms, tables from .models import * @@ -156,20 +180,6 @@ class JobBulkDeleteView(generic.BulkDeleteView): # Config Revisions # -class ConfigView(generic.ObjectView): - queryset = ConfigRevision.objects.all() - - def get_object(self, **kwargs): - revision_id = cache.get('config_version') - try: - return ConfigRevision.objects.get(pk=revision_id) - except ConfigRevision.DoesNotExist: - # Fall back to using the active config data if no record is found - return ConfigRevision( - data=get_config().defaults - ) - - class ConfigRevisionListView(generic.ObjectListView): queryset = ConfigRevision.objects.all() filterset = filtersets.ConfigRevisionFilterSet @@ -232,3 +242,345 @@ class ConfigRevisionRestoreView(ContentTypePermissionRequiredMixin, View): messages.success(request, f"Restored configuration revision #{pk}") return redirect(candidate_config.get_absolute_url()) + + +# +# Background Tasks (RQ) +# + +class BaseRQView(UserPassesTestMixin, View): + + def test_func(self): + return self.request.user.is_staff + + +class BackgroundQueueListView(TableMixin, BaseRQView): + table = tables.BackgroundQueueTable + + def get(self, request): + data = get_statistics(run_maintenance_tasks=True)["queues"] + table = self.get_table(data, request, bulk_actions=False) + + return render(request, 'core/rq_queue_list.html', { + 'table': table, + }) + + +class BackgroundTaskListView(TableMixin, BaseRQView): + table = tables.BackgroundTaskTable + + def get_table_data(self, request, queue, status): + jobs = [] + + # Call get_jobs() to returned queued tasks + if status == RQJobStatus.QUEUED: + return queue.get_jobs() + + # For other statuses, determine the registry to list (or raise a 404 for invalid statuses) + try: + registry_cls = { + RQJobStatus.STARTED: StartedJobRegistry, + RQJobStatus.DEFERRED: DeferredJobRegistry, + RQJobStatus.FINISHED: FinishedJobRegistry, + RQJobStatus.FAILED: FailedJobRegistry, + RQJobStatus.SCHEDULED: ScheduledJobRegistry, + }[status] + except KeyError: + raise Http404 + registry = registry_cls(queue.name, queue.connection) + + job_ids = registry.get_job_ids() + if status != RQJobStatus.DEFERRED: + jobs = get_jobs(queue, job_ids, registry) + else: + # Deferred jobs require special handling + for job_id in job_ids: + try: + jobs.append(RQ_Job.fetch(job_id, connection=queue.connection, serializer=queue.serializer)) + except NoSuchJobError: + pass + + if jobs and status == RQJobStatus.SCHEDULED: + for job in jobs: + job.scheduled_at = registry.get_scheduled_time(job) + + return jobs + + def get(self, request, queue_index, status): + queue = get_queue_by_index(queue_index) + data = self.get_table_data(request, queue, status) + table = self.get_table(data, request, False) + + # If this is an HTMX request, return only the rendered table HTML + if htmx_partial(request): + return render(request, 'htmx/table.html', { + 'table': table, + }) + + return render(request, 'core/rq_task_list.html', { + 'table': table, + 'queue': queue, + 'status': status, + }) + + +class BackgroundTaskView(BaseRQView): + + def get(self, request, job_id): + # all the RQ queues should use the same connection + config = QUEUES_LIST[0] + try: + job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),) + except NoSuchJobError: + raise Http404(_("Job {job_id} not found").format(job_id=job_id)) + + queue_index = QUEUES_MAP[job.origin] + queue = get_queue_by_index(queue_index) + + try: + exc_info = job._exc_info + except AttributeError: + exc_info = None + + return render(request, 'core/rq_task.html', { + 'queue': queue, + 'job': job, + 'queue_index': queue_index, + 'dependency_id': job._dependency_id, + 'exc_info': exc_info, + }) + + +class BackgroundTaskDeleteView(BaseRQView): + + def get(self, request, job_id): + if not request.htmx: + return redirect(reverse('core:background_queue_list')) + + form = ConfirmationForm(initial=request.GET) + + return render(request, 'htmx/delete_form.html', { + 'object_type': 'background task', + 'object': job_id, + 'form': form, + 'form_url': reverse('core:background_task_delete', kwargs={'job_id': job_id}) + }) + + def post(self, request, job_id): + form = ConfirmationForm(request.POST) + + if form.is_valid(): + # all the RQ queues should use the same connection + config = QUEUES_LIST[0] + try: + job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),) + except NoSuchJobError: + raise Http404(_("Job {job_id} not found").format(job_id=job_id)) + + queue_index = QUEUES_MAP[job.origin] + queue = get_queue_by_index(queue_index) + + # Remove job id from queue and delete the actual job + queue.connection.lrem(queue.key, 0, job.id) + job.delete() + messages.success(request, f'Deleted job {job_id}') + else: + messages.error(request, f'Error deleting job: {form.errors[0]}') + + return redirect(reverse('core:background_queue_list')) + + +class BackgroundTaskRequeueView(BaseRQView): + + def get(self, request, job_id): + # all the RQ queues should use the same connection + config = QUEUES_LIST[0] + try: + job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),) + except NoSuchJobError: + raise Http404(_("Job {job_id} not found").format(job_id=job_id)) + + queue_index = QUEUES_MAP[job.origin] + queue = get_queue_by_index(queue_index) + + requeue_job(job_id, connection=queue.connection, serializer=queue.serializer) + messages.success(request, f'You have successfully requeued: {job_id}') + return redirect(reverse('core:background_task', args=[job_id])) + + +class BackgroundTaskEnqueueView(BaseRQView): + + def get(self, request, job_id): + # all the RQ queues should use the same connection + config = QUEUES_LIST[0] + try: + job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),) + except NoSuchJobError: + raise Http404(_("Job {job_id} not found").format(job_id=job_id)) + + queue_index = QUEUES_MAP[job.origin] + queue = get_queue_by_index(queue_index) + + try: + # _enqueue_job is new in RQ 1.14, this is used to enqueue + # job regardless of its dependencies + queue._enqueue_job(job) + except AttributeError: + queue.enqueue_job(job) + + # Remove job from correct registry if needed + if job.get_status() == RQJobStatus.DEFERRED: + registry = DeferredJobRegistry(queue.name, queue.connection) + registry.remove(job) + elif job.get_status() == RQJobStatus.FINISHED: + registry = FinishedJobRegistry(queue.name, queue.connection) + registry.remove(job) + elif job.get_status() == RQJobStatus.SCHEDULED: + registry = ScheduledJobRegistry(queue.name, queue.connection) + registry.remove(job) + + messages.success(request, f'You have successfully enqueued: {job_id}') + return redirect(reverse('core:background_task', args=[job_id])) + + +class BackgroundTaskStopView(BaseRQView): + + def get(self, request, job_id): + # all the RQ queues should use the same connection + config = QUEUES_LIST[0] + try: + job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),) + except NoSuchJobError: + raise Http404(_("Job {job_id} not found").format(job_id=job_id)) + + queue_index = QUEUES_MAP[job.origin] + queue = get_queue_by_index(queue_index) + + stopped, _ = stop_jobs(queue, job_id) + if len(stopped) == 1: + messages.success(request, f'You have successfully stopped {job_id}') + else: + messages.error(request, f'Failed to stop {job_id}') + + return redirect(reverse('core:background_task', args=[job_id])) + + +class WorkerListView(TableMixin, BaseRQView): + table = tables.WorkerTable + + def get_table_data(self, request, queue): + clean_worker_registry(queue) + all_workers = Worker.all(queue.connection) + workers = [worker for worker in all_workers if queue.name in worker.queue_names()] + return workers + + def get(self, request, queue_index): + queue = get_queue_by_index(queue_index) + data = self.get_table_data(request, queue) + + table = self.get_table(data, request, False) + + # If this is an HTMX request, return only the rendered table HTML + if htmx_partial(request): + if not request.htmx.target: + table.embedded = True + # Hide selection checkboxes + if 'pk' in table.base_columns: + table.columns.hide('pk') + return render(request, 'htmx/table.html', { + 'table': table, + 'queue': queue, + }) + + return render(request, 'core/rq_worker_list.html', { + 'table': table, + 'queue': queue, + }) + + +class WorkerView(BaseRQView): + + def get(self, request, key): + # all the RQ queues should use the same connection + config = QUEUES_LIST[0] + worker = Worker.find_by_key('rq:worker:' + key, connection=get_redis_connection(config['connection_config'])) + # Convert microseconds to milliseconds + worker.total_working_time = worker.total_working_time / 1000 + + return render(request, 'core/rq_worker.html', { + 'worker': worker, + 'job': worker.get_current_job(), + 'total_working_time': worker.total_working_time * 1000, + }) + + +# +# Plugins +# + +class SystemView(UserPassesTestMixin, View): + + def test_func(self): + return self.request.user.is_staff + + def get(self, request): + + # System stats + psql_version = db_name = db_size = None + try: + with connection.cursor() as cursor: + cursor.execute("SELECT version()") + psql_version = cursor.fetchone()[0] + psql_version = psql_version.split('(')[0].strip() + cursor.execute("SELECT current_database()") + db_name = cursor.fetchone()[0] + cursor.execute(f"SELECT pg_size_pretty(pg_database_size('{db_name}'))") + db_size = cursor.fetchone()[0] + except (ProgrammingError, IndexError): + pass + stats = { + 'netbox_version': settings.VERSION, + 'django_version': DJANGO_VERSION, + 'python_version': platform.python_version(), + 'postgresql_version': psql_version, + 'database_name': db_name, + 'database_size': db_size, + 'rq_worker_count': Worker.count(get_connection('default')), + } + + # Plugins + plugins = [ + # Look up app config by package name + apps.get_app_config(plugin.rsplit('.', 1)[-1]) for plugin in settings.PLUGINS + ] + + # Configuration + try: + config = ConfigRevision.objects.get(pk=cache.get('config_version')) + except ConfigRevision.DoesNotExist: + # Fall back to using the active config data if no record is found + config = ConfigRevision(data=get_config().defaults) + + # Raw data export + if 'export' in request.GET: + data = { + **stats, + 'plugins': { + plugin.name: plugin.version for plugin in plugins + }, + 'config': { + k: config.data[k] for k in sorted(config.data) + }, + } + response = HttpResponse(json.dumps(data, indent=4), content_type='text/json') + response['Content-Disposition'] = 'attachment; filename="netbox.json"' + return response + + plugins_table = tables.PluginTable(plugins, orderable=False) + plugins_table.configure(request) + + return render(request, 'core/system.html', { + 'stats': stats, + 'plugins_table': plugins_table, + 'config': config, + }) diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 419d9b175..bcb141a71 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -2,11 +2,10 @@ from drf_spectacular.utils import extend_schema_serializer from rest_framework import serializers from dcim import models -from netbox.api.serializers import BaseModelSerializer, WritableNestedSerializer +from netbox.api.fields import RelatedObjectCountField +from netbox.api.serializers import WritableNestedSerializer __all__ = [ - 'ComponentNestedModuleSerializer', - 'ModuleBayNestedModuleSerializer', 'NestedCableSerializer', 'NestedConsolePortSerializer', 'NestedConsolePortTemplateSerializer', @@ -110,7 +109,7 @@ class NestedLocationSerializer(WritableNestedSerializer): ) class NestedRackRoleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') - rack_count = serializers.IntegerField(read_only=True) + rack_count = RelatedObjectCountField('racks') class Meta: model = models.RackRole @@ -122,7 +121,7 @@ class NestedRackRoleSerializer(WritableNestedSerializer): ) class NestedRackSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail') - device_count = serializers.IntegerField(read_only=True) + device_count = RelatedObjectCountField('devices') class Meta: model = models.Rack @@ -150,7 +149,7 @@ class NestedRackReservationSerializer(WritableNestedSerializer): ) class NestedManufacturerSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') - devicetype_count = serializers.IntegerField(read_only=True) + devicetype_count = RelatedObjectCountField('device_types') class Meta: model = models.Manufacturer @@ -163,7 +162,7 @@ class NestedManufacturerSerializer(WritableNestedSerializer): class NestedDeviceTypeSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') manufacturer = NestedManufacturerSerializer(read_only=True) - device_count = serializers.IntegerField(read_only=True) + device_count = RelatedObjectCountField('instances') class Meta: model = models.DeviceType @@ -173,7 +172,6 @@ class NestedDeviceTypeSerializer(WritableNestedSerializer): class NestedModuleTypeSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail') manufacturer = NestedManufacturerSerializer(read_only=True) - # module_count = serializers.IntegerField(read_only=True) class Meta: model = models.ModuleType @@ -274,8 +272,8 @@ class NestedInventoryItemTemplateSerializer(WritableNestedSerializer): ) class NestedDeviceRoleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') - device_count = serializers.IntegerField(read_only=True) - virtualmachine_count = serializers.IntegerField(read_only=True) + device_count = RelatedObjectCountField('devices') + virtualmachine_count = RelatedObjectCountField('virtual_machines') class Meta: model = models.DeviceRole @@ -287,8 +285,8 @@ class NestedDeviceRoleSerializer(WritableNestedSerializer): ) class NestedPlatformSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') - device_count = serializers.IntegerField(read_only=True) - virtualmachine_count = serializers.IntegerField(read_only=True) + device_count = RelatedObjectCountField('devices') + virtualmachine_count = RelatedObjectCountField('virtual_machines') class Meta: model = models.Platform @@ -319,18 +317,6 @@ class ModuleBayNestedModuleSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'serial'] -class ComponentNestedModuleSerializer(WritableNestedSerializer): - """ - Used by device component serializers. - """ - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') - module_bay = ModuleNestedModuleBaySerializer(read_only=True) - - class Meta: - model = models.Module - fields = ['id', 'url', 'display', 'device', 'module_bay'] - - class NestedModuleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') device = NestedDeviceSerializer(read_only=True) @@ -445,7 +431,7 @@ class NestedInventoryItemSerializer(WritableNestedSerializer): ) class NestedInventoryItemRoleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail') - inventoryitem_count = serializers.IntegerField(read_only=True) + inventoryitem_count = RelatedObjectCountField('inventory_items') class Meta: model = models.InventoryItemRole @@ -490,7 +476,7 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer): ) class NestedPowerPanelSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail') - powerfeed_count = serializers.IntegerField(read_only=True) + powerfeed_count = RelatedObjectCountField('powerfeeds') class Meta: model = models.PowerPanel diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index ab3177de5..4f8bbac17 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1,1254 +1,14 @@ -import decimal - -from django.contrib.contenttypes.models import ContentType -from django.utils.translation import gettext as _ -from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import extend_schema_field -from rest_framework import serializers -from timezone_field.rest_framework import TimeZoneSerializerField - -from dcim.choices import * -from dcim.constants import * -from dcim.models import * -from extras.api.nested_serializers import NestedConfigTemplateSerializer -from ipam.api.nested_serializers import ( - NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer, -) -from ipam.models import ASN, VLAN -from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField -from netbox.api.serializers import ( - GenericObjectSerializer, NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer, - WritableNestedSerializer, -) -from netbox.config import ConfigItem -from netbox.constants import NESTED_SERIALIZER_PREFIX -from tenancy.api.nested_serializers import NestedTenantSerializer -from users.api.nested_serializers import NestedUserSerializer -from utilities.api import get_serializer_for_model -from virtualization.api.nested_serializers import NestedClusterSerializer -from vpn.api.nested_serializers import NestedL2VPNTerminationSerializer -from wireless.api.nested_serializers import NestedWirelessLANSerializer, NestedWirelessLinkSerializer -from wireless.choices import * -from wireless.models import WirelessLAN +from .serializers_.cables import * +from .serializers_.sites import * +from .serializers_.racks import * +from .serializers_.manufacturers import * +from .serializers_.platforms import * +from .serializers_.roles import * +from .serializers_.devicetypes import * +from .serializers_.devicetype_components import * +from .serializers_.virtualchassis import * +from .serializers_.devices import * +from .serializers_.device_components import * +from .serializers_.power import * +from .serializers_.rackunits import * from .nested_serializers import * - - -class CabledObjectSerializer(serializers.ModelSerializer): - cable = NestedCableSerializer(read_only=True, allow_null=True) - cable_end = serializers.CharField(read_only=True) - link_peers_type = serializers.SerializerMethodField(read_only=True) - link_peers = serializers.SerializerMethodField(read_only=True) - _occupied = serializers.SerializerMethodField(read_only=True) - - @extend_schema_field(OpenApiTypes.STR) - def get_link_peers_type(self, obj): - """ - Return the type of the peer link terminations, or None. - """ - if not obj.cable: - return None - - if obj.link_peers: - return f'{obj.link_peers[0]._meta.app_label}.{obj.link_peers[0]._meta.model_name}' - - return None - - @extend_schema_field(serializers.ListField) - def get_link_peers(self, obj): - """ - Return the appropriate serializer for the link termination model. - """ - if not obj.link_peers: - return [] - - # Return serialized peer termination objects - serializer = get_serializer_for_model(obj.link_peers[0], prefix=NESTED_SERIALIZER_PREFIX) - context = {'request': self.context['request']} - return serializer(obj.link_peers, context=context, many=True).data - - @extend_schema_field(serializers.BooleanField) - def get__occupied(self, obj): - return obj._occupied - - -class ConnectedEndpointsSerializer(serializers.ModelSerializer): - """ - Legacy serializer for pre-v3.3 connections - """ - connected_endpoints_type = serializers.SerializerMethodField(read_only=True) - connected_endpoints = serializers.SerializerMethodField(read_only=True) - connected_endpoints_reachable = serializers.SerializerMethodField(read_only=True) - - @extend_schema_field(OpenApiTypes.STR) - def get_connected_endpoints_type(self, obj): - if endpoints := obj.connected_endpoints: - return f'{endpoints[0]._meta.app_label}.{endpoints[0]._meta.model_name}' - - @extend_schema_field(serializers.ListField) - def get_connected_endpoints(self, obj): - """ - Return the appropriate serializer for the type of connected object. - """ - if endpoints := obj.connected_endpoints: - serializer = get_serializer_for_model(endpoints[0], prefix=NESTED_SERIALIZER_PREFIX) - context = {'request': self.context['request']} - return serializer(endpoints, many=True, context=context).data - - @extend_schema_field(serializers.BooleanField) - def get_connected_endpoints_reachable(self, obj): - return obj._path and obj._path.is_complete and obj._path.is_active - - -# -# Regions/sites -# - -class RegionSerializer(NestedGroupModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail') - parent = NestedRegionSerializer(required=False, allow_null=True, default=None) - site_count = serializers.IntegerField(read_only=True) - - class Meta: - model = Region - fields = [ - 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created', - 'last_updated', 'site_count', '_depth', - ] - - -class SiteGroupSerializer(NestedGroupModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail') - parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None) - site_count = serializers.IntegerField(read_only=True) - - class Meta: - model = SiteGroup - fields = [ - 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created', - 'last_updated', 'site_count', '_depth', - ] - - -class SiteSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail') - status = ChoiceField(choices=SiteStatusChoices, required=False) - region = NestedRegionSerializer(required=False, allow_null=True) - group = NestedSiteGroupSerializer(required=False, allow_null=True) - tenant = NestedTenantSerializer(required=False, allow_null=True) - time_zone = TimeZoneSerializerField(required=False, allow_null=True) - asns = SerializedPKRelatedField( - queryset=ASN.objects.all(), - serializer=NestedASNSerializer, - required=False, - many=True - ) - - # Related object counts - circuit_count = serializers.IntegerField(read_only=True) - device_count = serializers.IntegerField(read_only=True) - prefix_count = serializers.IntegerField(read_only=True) - rack_count = serializers.IntegerField(read_only=True) - virtualmachine_count = serializers.IntegerField(read_only=True) - vlan_count = serializers.IntegerField(read_only=True) - - class Meta: - model = Site - fields = [ - 'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', - 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'asns', 'tags', - 'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'prefix_count', 'rack_count', - 'virtualmachine_count', 'vlan_count', - ] - - -# -# Racks -# - -class LocationSerializer(NestedGroupModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail') - site = NestedSiteSerializer() - parent = NestedLocationSerializer(required=False, allow_null=True) - status = ChoiceField(choices=LocationStatusChoices, required=False) - tenant = NestedTenantSerializer(required=False, allow_null=True) - rack_count = serializers.IntegerField(read_only=True) - device_count = serializers.IntegerField(read_only=True) - - class Meta: - model = Location - fields = [ - 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'description', 'tags', - 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth', - ] - - -class RackRoleSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') - rack_count = serializers.IntegerField(read_only=True) - - class Meta: - model = RackRole - fields = [ - 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created', - 'last_updated', 'rack_count', - ] - - -class RackSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail') - site = NestedSiteSerializer() - location = NestedLocationSerializer(required=False, allow_null=True, default=None) - tenant = NestedTenantSerializer(required=False, allow_null=True) - status = ChoiceField(choices=RackStatusChoices, required=False) - role = NestedRackRoleSerializer(required=False, allow_null=True) - type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False, allow_null=True) - facility_id = serializers.CharField(max_length=50, allow_blank=True, allow_null=True, label=_('Facility ID'), - default=None) - width = ChoiceField(choices=RackWidthChoices, required=False) - outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False, allow_null=True) - weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True) - device_count = serializers.IntegerField(read_only=True) - powerfeed_count = serializers.IntegerField(read_only=True) - - class Meta: - model = Rack - fields = [ - 'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial', - 'asset_tag', 'type', 'width', 'u_height', 'starting_unit', 'weight', 'max_weight', 'weight_unit', - 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments', - 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count', - ] - - -class RackUnitSerializer(serializers.Serializer): - """ - A rack unit is an abstraction formed by the set (rack, position, face); it does not exist as a row in the database. - """ - id = serializers.DecimalField( - max_digits=4, - decimal_places=1, - read_only=True - ) - name = serializers.CharField(read_only=True) - face = ChoiceField(choices=DeviceFaceChoices, read_only=True) - device = NestedDeviceSerializer(read_only=True) - occupied = serializers.BooleanField(read_only=True) - display = serializers.SerializerMethodField(read_only=True) - - @extend_schema_field(OpenApiTypes.STR) - def get_display(self, obj): - return obj['name'] - - -class RackReservationSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail') - rack = NestedRackSerializer() - user = NestedUserSerializer() - tenant = NestedTenantSerializer(required=False, allow_null=True) - - class Meta: - model = RackReservation - fields = [ - 'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description', - 'comments', 'tags', 'custom_fields', - ] - - -class RackElevationDetailFilterSerializer(serializers.Serializer): - q = serializers.CharField( - required=False, - default=None - ) - face = serializers.ChoiceField( - choices=DeviceFaceChoices, - default=DeviceFaceChoices.FACE_FRONT - ) - render = serializers.ChoiceField( - choices=RackElevationDetailRenderChoices, - default=RackElevationDetailRenderChoices.RENDER_JSON - ) - unit_width = serializers.IntegerField( - default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_WIDTH') - ) - unit_height = serializers.IntegerField( - default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT') - ) - legend_width = serializers.IntegerField( - default=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH - ) - margin_width = serializers.IntegerField( - default=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH - ) - exclude = serializers.IntegerField( - required=False, - default=None - ) - expand_devices = serializers.BooleanField( - required=False, - default=True - ) - include_images = serializers.BooleanField( - required=False, - default=True - ) - - -# -# Device/module types -# - -class ManufacturerSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') - devicetype_count = serializers.IntegerField(read_only=True) - inventoryitem_count = serializers.IntegerField(read_only=True) - platform_count = serializers.IntegerField(read_only=True) - - class Meta: - model = Manufacturer - fields = [ - 'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated', - 'devicetype_count', 'inventoryitem_count', 'platform_count', - ] - - -class DeviceTypeSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') - manufacturer = NestedManufacturerSerializer() - default_platform = NestedPlatformSerializer(required=False, allow_null=True) - u_height = serializers.DecimalField( - max_digits=4, - decimal_places=1, - label=_('Position (U)'), - min_value=0, - default=1.0 - ) - subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False, allow_null=True) - airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False, allow_null=True) - weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True) - device_count = serializers.IntegerField(read_only=True) - front_image = serializers.URLField(allow_null=True, required=False) - rear_image = serializers.URLField(allow_null=True, required=False) - - # Counter fields - console_port_template_count = serializers.IntegerField(read_only=True) - console_server_port_template_count = serializers.IntegerField(read_only=True) - power_port_template_count = serializers.IntegerField(read_only=True) - power_outlet_template_count = serializers.IntegerField(read_only=True) - interface_template_count = serializers.IntegerField(read_only=True) - front_port_template_count = serializers.IntegerField(read_only=True) - rear_port_template_count = serializers.IntegerField(read_only=True) - 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) - - class Meta: - model = DeviceType - fields = [ - 'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', - 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', - 'front_image', 'rear_image', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', - 'device_count', 'console_port_template_count', 'console_server_port_template_count', 'power_port_template_count', - 'power_outlet_template_count', 'interface_template_count', 'front_port_template_count', - 'rear_port_template_count', 'device_bay_template_count', 'module_bay_template_count', - 'inventory_item_template_count', - ] - - -class ModuleTypeSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail') - manufacturer = NestedManufacturerSerializer() - weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True) - - class Meta: - model = ModuleType - fields = [ - 'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'description', - 'comments', 'tags', 'custom_fields', 'created', 'last_updated', - ] - - -# -# Component templates -# - -class ConsolePortTemplateSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail') - device_type = NestedDeviceTypeSerializer( - required=False, - allow_null=True, - default=None - ) - module_type = NestedModuleTypeSerializer( - required=False, - allow_null=True, - default=None - ) - type = ChoiceField( - choices=ConsolePortTypeChoices, - allow_blank=True, - required=False - ) - - class Meta: - model = ConsolePortTemplate - fields = [ - 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created', - 'last_updated', - ] - - -class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverporttemplate-detail') - device_type = NestedDeviceTypeSerializer( - required=False, - allow_null=True, - default=None - ) - module_type = NestedModuleTypeSerializer( - required=False, - allow_null=True, - default=None - ) - type = ChoiceField( - choices=ConsolePortTypeChoices, - allow_blank=True, - required=False - ) - - class Meta: - model = ConsoleServerPortTemplate - fields = [ - 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created', - 'last_updated', - ] - - -class PowerPortTemplateSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail') - device_type = NestedDeviceTypeSerializer( - required=False, - allow_null=True, - default=None - ) - module_type = NestedModuleTypeSerializer( - required=False, - allow_null=True, - default=None - ) - type = ChoiceField( - choices=PowerPortTypeChoices, - allow_blank=True, - required=False, - allow_null=True - ) - - class Meta: - model = PowerPortTemplate - fields = [ - 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', - 'allocated_draw', 'description', 'created', 'last_updated', - ] - - -class PowerOutletTemplateSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlettemplate-detail') - device_type = NestedDeviceTypeSerializer( - required=False, - allow_null=True, - default=None - ) - module_type = NestedModuleTypeSerializer( - required=False, - allow_null=True, - default=None - ) - type = ChoiceField( - choices=PowerOutletTypeChoices, - allow_blank=True, - required=False, - allow_null=True - ) - power_port = NestedPowerPortTemplateSerializer( - required=False, - allow_null=True - ) - feed_leg = ChoiceField( - choices=PowerOutletFeedLegChoices, - allow_blank=True, - required=False, - allow_null=True - ) - - class Meta: - model = PowerOutletTemplate - fields = [ - 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', - 'description', 'created', 'last_updated', - ] - - -class InterfaceTemplateSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfacetemplate-detail') - device_type = NestedDeviceTypeSerializer( - required=False, - allow_null=True, - default=None - ) - module_type = NestedModuleTypeSerializer( - required=False, - allow_null=True, - default=None - ) - type = ChoiceField(choices=InterfaceTypeChoices) - bridge = NestedInterfaceTemplateSerializer( - required=False, - allow_null=True - ) - poe_mode = ChoiceField( - choices=InterfacePoEModeChoices, - required=False, - allow_blank=True, - allow_null=True - ) - poe_type = ChoiceField( - choices=InterfacePoETypeChoices, - required=False, - allow_blank=True, - allow_null=True - ) - rf_role = ChoiceField( - choices=WirelessRoleChoices, - required=False, - allow_blank=True, - allow_null=True - ) - - class Meta: - model = InterfaceTemplate - fields = [ - 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', - 'description', 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'created', 'last_updated', - ] - - -class RearPortTemplateSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail') - device_type = NestedDeviceTypeSerializer( - required=False, - allow_null=True, - default=None - ) - module_type = NestedModuleTypeSerializer( - required=False, - allow_null=True, - default=None - ) - type = ChoiceField(choices=PortTypeChoices) - - class Meta: - model = RearPortTemplate - fields = [ - 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', - 'description', 'created', 'last_updated', - ] - - -class FrontPortTemplateSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail') - device_type = NestedDeviceTypeSerializer( - required=False, - allow_null=True, - default=None - ) - module_type = NestedModuleTypeSerializer( - required=False, - allow_null=True, - default=None - ) - type = ChoiceField(choices=PortTypeChoices) - rear_port = NestedRearPortTemplateSerializer() - - class Meta: - model = FrontPortTemplate - fields = [ - 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', - 'rear_port_position', 'description', 'created', 'last_updated', - ] - - -class ModuleBayTemplateSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebaytemplate-detail') - device_type = NestedDeviceTypeSerializer() - - class Meta: - model = ModuleBayTemplate - fields = [ - 'id', 'url', 'display', 'device_type', 'name', 'label', 'position', 'description', 'created', - 'last_updated', - ] - - -class DeviceBayTemplateSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail') - device_type = NestedDeviceTypeSerializer() - - class Meta: - model = DeviceBayTemplate - fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated'] - - -class InventoryItemTemplateSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemtemplate-detail') - device_type = NestedDeviceTypeSerializer() - parent = serializers.PrimaryKeyRelatedField( - queryset=InventoryItemTemplate.objects.all(), - allow_null=True, - default=None - ) - role = NestedInventoryItemRoleSerializer(required=False, allow_null=True) - manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None) - component_type = ContentTypeField( - queryset=ContentType.objects.filter(MODULAR_COMPONENT_TEMPLATE_MODELS), - required=False, - allow_null=True - ) - component = serializers.SerializerMethodField(read_only=True) - _depth = serializers.IntegerField(source='level', read_only=True) - - class Meta: - model = InventoryItemTemplate - fields = [ - 'id', 'url', 'display', 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', - 'description', 'component_type', 'component_id', 'component', 'created', 'last_updated', '_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, prefix=NESTED_SERIALIZER_PREFIX) - context = {'request': self.context['request']} - return serializer(obj.component, context=context).data - - -# -# Devices -# - -class DeviceRoleSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') - config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None) - device_count = serializers.IntegerField(read_only=True) - virtualmachine_count = serializers.IntegerField(read_only=True) - - class Meta: - model = DeviceRole - fields = [ - 'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags', - 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', - ] - - -class PlatformSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') - manufacturer = NestedManufacturerSerializer(required=False, allow_null=True) - config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None) - device_count = serializers.IntegerField(read_only=True) - virtualmachine_count = serializers.IntegerField(read_only=True) - - class Meta: - model = Platform - fields = [ - 'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags', - 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', - ] - - -class DeviceSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') - device_type = NestedDeviceTypeSerializer() - role = NestedDeviceRoleSerializer() - device_role = NestedDeviceRoleSerializer(read_only=True, help_text='Deprecated in v3.6 in favor of `role`.') - tenant = NestedTenantSerializer(required=False, allow_null=True, default=None) - platform = NestedPlatformSerializer(required=False, allow_null=True) - site = NestedSiteSerializer() - location = NestedLocationSerializer(required=False, allow_null=True, default=None) - rack = NestedRackSerializer(required=False, allow_null=True, default=None) - face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, default=lambda: '') - position = serializers.DecimalField( - max_digits=4, - decimal_places=1, - allow_null=True, - label=_('Position (U)'), - min_value=decimal.Decimal(0.5), - default=None - ) - status = ChoiceField(choices=DeviceStatusChoices, required=False) - airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) - primary_ip = NestedIPAddressSerializer(read_only=True) - primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) - primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) - oob_ip = NestedIPAddressSerializer(required=False, allow_null=True) - parent_device = serializers.SerializerMethodField() - cluster = NestedClusterSerializer(required=False, allow_null=True) - virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True, default=None) - vc_position = serializers.IntegerField(allow_null=True, max_value=255, min_value=0, default=None) - config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None) - - # Counter fields - console_port_count = serializers.IntegerField(read_only=True) - console_server_port_count = serializers.IntegerField(read_only=True) - power_port_count = serializers.IntegerField(read_only=True) - power_outlet_count = serializers.IntegerField(read_only=True) - interface_count = serializers.IntegerField(read_only=True) - front_port_count = serializers.IntegerField(read_only=True) - rear_port_count = serializers.IntegerField(read_only=True) - device_bay_count = serializers.IntegerField(read_only=True) - module_bay_count = serializers.IntegerField(read_only=True) - inventory_item_count = serializers.IntegerField(read_only=True) - - class Meta: - model = Device - fields = [ - 'id', 'url', 'display', 'name', 'device_type', 'role', 'device_role', 'tenant', 'platform', 'serial', - 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', - 'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', - 'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags', - 'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count', - 'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count', - 'device_bay_count', 'module_bay_count', 'inventory_item_count', - ] - - @extend_schema_field(NestedDeviceSerializer) - def get_parent_device(self, obj): - try: - device_bay = obj.parent_bay - except DeviceBay.DoesNotExist: - return None - context = {'request': self.context['request']} - data = NestedDeviceSerializer(instance=device_bay.device, context=context).data - data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data - return data - - def get_device_role(self, obj): - return obj.role - - -class DeviceWithConfigContextSerializer(DeviceSerializer): - config_context = serializers.SerializerMethodField(read_only=True) - - class Meta(DeviceSerializer.Meta): - fields = [ - 'id', 'url', 'display', 'name', 'device_type', 'role', 'device_role', 'tenant', 'platform', 'serial', - 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', - 'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', - 'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'config_context', - 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'console_port_count', - 'console_server_port_count', 'power_port_count', 'power_outlet_count', 'interface_count', - 'front_port_count', 'rear_port_count', 'device_bay_count', 'module_bay_count', 'inventory_item_count', - ] - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_config_context(self, obj): - return obj.get_config_context() - - -class VirtualDeviceContextSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail') - device = NestedDeviceSerializer() - tenant = NestedTenantSerializer(required=False, allow_null=True, default=None) - primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True) - primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) - primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) - status = ChoiceField(choices=VirtualDeviceContextStatusChoices) - - # Related object counts - interface_count = serializers.IntegerField(read_only=True) - - class Meta: - model = VirtualDeviceContext - fields = [ - 'id', 'url', 'display', 'name', 'device', 'identifier', 'tenant', 'primary_ip', 'primary_ip4', - 'primary_ip6', 'status', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', - 'interface_count', - ] - - -class ModuleSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') - device = NestedDeviceSerializer() - module_bay = NestedModuleBaySerializer() - module_type = NestedModuleTypeSerializer() - status = ChoiceField(choices=ModuleStatusChoices, required=False) - - class Meta: - model = Module - fields = [ - 'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag', - 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', - ] - - -# -# Device components -# - -class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') - device = NestedDeviceSerializer() - module = ComponentNestedModuleSerializer( - required=False, - allow_null=True - ) - type = ChoiceField( - choices=ConsolePortTypeChoices, - allow_blank=True, - required=False - ) - speed = ChoiceField( - choices=ConsolePortSpeedChoices, - allow_null=True, - required=False - ) - - class Meta: - model = ConsoleServerPort - fields = [ - 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description', - 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints', - 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', - 'last_updated', '_occupied', - ] - - -class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') - device = NestedDeviceSerializer() - module = ComponentNestedModuleSerializer( - required=False, - allow_null=True - ) - type = ChoiceField( - choices=ConsolePortTypeChoices, - allow_blank=True, - required=False - ) - speed = ChoiceField( - choices=ConsolePortSpeedChoices, - allow_null=True, - required=False - ) - - class Meta: - model = ConsolePort - fields = [ - 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description', - 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints', - 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', - 'last_updated', '_occupied', - ] - - -class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') - device = NestedDeviceSerializer() - module = ComponentNestedModuleSerializer( - required=False, - allow_null=True - ) - type = ChoiceField( - choices=PowerOutletTypeChoices, - allow_blank=True, - required=False, - allow_null=True - ) - power_port = NestedPowerPortSerializer( - required=False, - allow_null=True - ) - feed_leg = ChoiceField( - choices=PowerOutletFeedLegChoices, - allow_blank=True, - required=False, - allow_null=True - ) - - class Meta: - model = PowerOutlet - fields = [ - 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', - 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', - 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', - 'created', 'last_updated', '_occupied', - ] - - -class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') - device = NestedDeviceSerializer() - module = ComponentNestedModuleSerializer( - required=False, - allow_null=True - ) - type = ChoiceField( - choices=PowerPortTypeChoices, - allow_blank=True, - required=False, - allow_null=True - ) - - class Meta: - model = PowerPort - fields = [ - 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', - 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', - 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', - 'created', 'last_updated', '_occupied', - ] - - -class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') - device = NestedDeviceSerializer() - vdcs = SerializedPKRelatedField( - queryset=VirtualDeviceContext.objects.all(), - serializer=NestedVirtualDeviceContextSerializer, - required=False, - many=True - ) - module = ComponentNestedModuleSerializer( - required=False, - allow_null=True - ) - type = ChoiceField(choices=InterfaceTypeChoices) - parent = NestedInterfaceSerializer(required=False, allow_null=True) - bridge = NestedInterfaceSerializer(required=False, allow_null=True) - lag = NestedInterfaceSerializer(required=False, allow_null=True) - mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True) - duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True, allow_null=True) - rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True) - rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True) - poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True) - poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True) - untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) - tagged_vlans = SerializedPKRelatedField( - queryset=VLAN.objects.all(), - serializer=NestedVLANSerializer, - required=False, - many=True - ) - vrf = NestedVRFSerializer(required=False, allow_null=True) - l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True, allow_null=True) - wireless_link = NestedWirelessLinkSerializer(read_only=True, allow_null=True) - wireless_lans = SerializedPKRelatedField( - queryset=WirelessLAN.objects.all(), - serializer=NestedWirelessLANSerializer, - required=False, - many=True - ) - count_ipaddresses = serializers.IntegerField(read_only=True) - count_fhrp_groups = serializers.IntegerField(read_only=True) - mac_address = serializers.CharField( - required=False, - default=None, - allow_blank=True, - allow_null=True - ) - wwn = serializers.CharField(required=False, default=None, allow_blank=True, allow_null=True) - - class Meta: - model = Interface - fields = [ - 'id', 'url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', - 'lag', 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', - 'rf_channel', 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', - 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers', - 'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints', - 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', - 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', - ] - - def validate(self, data): - - # Validate many-to-many VLAN assignments - device = self.instance.device if self.instance else data.get('device') - for vlan in data.get('tagged_vlans', []): - if vlan.site not in [device.site, None]: - raise serializers.ValidationError({ - 'tagged_vlans': f"VLAN {vlan} must belong to the same site as the interface's parent device, or " - f"it must be global." - }) - - return super().validate(data) - - -class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') - device = NestedDeviceSerializer() - module = ComponentNestedModuleSerializer( - required=False, - allow_null=True - ) - type = ChoiceField(choices=PortTypeChoices) - - class Meta: - model = RearPort - fields = [ - 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'description', - 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created', - 'last_updated', '_occupied', - ] - - -class FrontPortRearPortSerializer(WritableNestedSerializer): - """ - NestedRearPortSerializer but with parent device omitted (since front and rear ports must belong to same device) - """ - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') - - class Meta: - model = RearPort - fields = ['id', 'url', 'display', 'name', 'label', 'description'] - - -class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail') - device = NestedDeviceSerializer() - module = ComponentNestedModuleSerializer( - required=False, - allow_null=True - ) - type = ChoiceField(choices=PortTypeChoices) - rear_port = FrontPortRearPortSerializer() - - class Meta: - model = FrontPort - fields = [ - 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', - 'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', - 'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', - ] - - -class ModuleBaySerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail') - device = NestedDeviceSerializer() - installed_module = ModuleBayNestedModuleSerializer(required=False, allow_null=True) - - class Meta: - model = ModuleBay - fields = [ - 'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags', - 'custom_fields', 'created', 'last_updated', - ] - - -class DeviceBaySerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') - device = NestedDeviceSerializer() - installed_device = NestedDeviceSerializer(required=False, allow_null=True) - - class Meta: - model = DeviceBay - fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'description', 'installed_device', 'tags', - 'custom_fields', 'created', 'last_updated', - ] - - -class InventoryItemSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail') - device = NestedDeviceSerializer() - parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) - role = NestedInventoryItemRoleSerializer(required=False, allow_null=True) - manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None) - component_type = ContentTypeField( - queryset=ContentType.objects.filter(MODULAR_COMPONENT_MODELS), - required=False, - allow_null=True - ) - component = serializers.SerializerMethodField(read_only=True) - _depth = serializers.IntegerField(source='level', read_only=True) - - class Meta: - model = InventoryItem - fields = [ - 'id', 'url', 'display', 'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', - 'asset_tag', 'discovered', 'description', 'component_type', 'component_id', 'component', 'tags', - 'custom_fields', 'created', 'last_updated', '_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, prefix=NESTED_SERIALIZER_PREFIX) - context = {'request': self.context['request']} - return serializer(obj.component, context=context).data - - -# -# Device component roles -# - -class InventoryItemRoleSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail') - inventoryitem_count = serializers.IntegerField(read_only=True) - - class Meta: - model = InventoryItemRole - fields = [ - 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created', - 'last_updated', 'inventoryitem_count', - ] - - -# -# Cables -# - -class CableSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') - a_terminations = GenericObjectSerializer(many=True, required=False) - b_terminations = GenericObjectSerializer(many=True, required=False) - status = ChoiceField(choices=LinkStatusChoices, required=False) - tenant = NestedTenantSerializer(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', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant', 'label', 'color', - 'length', 'length_unit', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', - ] - - -class TracedCableSerializer(serializers.ModelSerializer): - """ - Used only while tracing a cable path. - """ - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') - - class Meta: - model = Cable - fields = [ - 'id', 'url', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'description', - ] - - -class CableTerminationSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cabletermination-detail') - termination_type = ContentTypeField( - queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS) - ) - termination = serializers.SerializerMethodField(read_only=True) - - class Meta: - model = CableTermination - fields = [ - 'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id', 'termination', - 'created', 'last_updated', - ] - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_termination(self, obj): - serializer = get_serializer_for_model(obj.termination, prefix=NESTED_SERIALIZER_PREFIX) - context = {'request': self.context['request']} - return serializer(obj.termination, context=context).data - - -class CablePathSerializer(serializers.ModelSerializer): - path = serializers.SerializerMethodField(read_only=True) - - class Meta: - model = CablePath - fields = ['id', 'path', 'is_active', 'is_complete', 'is_split'] - - @extend_schema_field(serializers.ListField) - def get_path(self, obj): - ret = [] - for nodes in obj.path_objects: - serializer = get_serializer_for_model(nodes[0], prefix=NESTED_SERIALIZER_PREFIX) - context = {'request': self.context['request']} - ret.append(serializer(nodes, context=context, many=True).data) - return ret - - -# -# Virtual chassis -# - -class VirtualChassisSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') - master = NestedDeviceSerializer(required=False, allow_null=True, default=None) - - # Counter fields - member_count = serializers.IntegerField(read_only=True) - - class Meta: - model = VirtualChassis - fields = [ - 'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields', - 'created', 'last_updated', 'member_count', - ] - - -# -# Power panels -# - -class PowerPanelSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail') - site = NestedSiteSerializer() - location = NestedLocationSerializer( - required=False, - allow_null=True, - default=None - ) - powerfeed_count = serializers.IntegerField(read_only=True) - - class Meta: - model = PowerPanel - fields = [ - 'id', 'url', 'display', 'site', 'location', 'name', 'description', 'comments', 'tags', 'custom_fields', - 'powerfeed_count', 'created', 'last_updated', - ] - - -class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail') - power_panel = NestedPowerPanelSerializer() - rack = NestedRackSerializer( - required=False, - allow_null=True, - default=None - ) - type = ChoiceField( - choices=PowerFeedTypeChoices, - default=lambda: PowerFeedTypeChoices.TYPE_PRIMARY, - ) - status = ChoiceField( - choices=PowerFeedStatusChoices, - default=lambda: PowerFeedStatusChoices.STATUS_ACTIVE, - ) - supply = ChoiceField( - choices=PowerFeedSupplyChoices, - default=lambda: PowerFeedSupplyChoices.SUPPLY_AC, - ) - phase = ChoiceField( - choices=PowerFeedPhaseChoices, - default=lambda: PowerFeedPhaseChoices.PHASE_SINGLE, - ) - tenant = NestedTenantSerializer( - required=False, - allow_null=True - ) - - class Meta: - model = PowerFeed - fields = [ - 'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', - 'amperage', 'max_utilization', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', - 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'description', - 'tenant', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', - ] diff --git a/netbox/dcim/api/serializers_/__init__.py b/netbox/dcim/api/serializers_/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/dcim/api/serializers_/base.py b/netbox/dcim/api/serializers_/base.py new file mode 100644 index 000000000..48f4967e3 --- /dev/null +++ b/netbox/dcim/api/serializers_/base.py @@ -0,0 +1,37 @@ +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from utilities.api import get_serializer_for_model + +__all__ = ( + 'ConnectedEndpointsSerializer', +) + + +class ConnectedEndpointsSerializer(serializers.ModelSerializer): + """ + Legacy serializer for pre-v3.3 connections + """ + connected_endpoints_type = serializers.SerializerMethodField(read_only=True) + connected_endpoints = serializers.SerializerMethodField(read_only=True) + connected_endpoints_reachable = serializers.SerializerMethodField(read_only=True) + + @extend_schema_field(OpenApiTypes.STR) + def get_connected_endpoints_type(self, obj): + if endpoints := obj.connected_endpoints: + return f'{endpoints[0]._meta.app_label}.{endpoints[0]._meta.model_name}' + + @extend_schema_field(serializers.ListField) + def get_connected_endpoints(self, obj): + """ + Return the appropriate serializer for the type of connected object. + """ + if endpoints := obj.connected_endpoints: + serializer = get_serializer_for_model(endpoints[0]) + context = {'request': self.context['request']} + return serializer(endpoints, nested=True, many=True, context=context).data + + @extend_schema_field(serializers.BooleanField) + def get_connected_endpoints_reachable(self, obj): + return obj._path and obj._path.is_complete and obj._path.is_active diff --git a/netbox/dcim/api/serializers_/cables.py b/netbox/dcim/api/serializers_/cables.py new file mode 100644 index 000000000..94a125d0c --- /dev/null +++ b/netbox/dcim/api/serializers_/cables.py @@ -0,0 +1,126 @@ +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 GenericObjectSerializer, NetBoxModelSerializer +from tenancy.api.serializers_.tenants import TenantSerializer +from utilities.api import get_serializer_for_model + +__all__ = ( + 'CablePathSerializer', + 'CableSerializer', + 'CableTerminationSerializer', + 'CabledObjectSerializer', + 'TracedCableSerializer', +) + + +class CableSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') + a_terminations = GenericObjectSerializer(many=True, required=False) + b_terminations = GenericObjectSerializer(many=True, required=False) + status = ChoiceField(choices=LinkStatusChoices, 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', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant', 'label', 'color', + 'length', 'length_unit', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'label', 'description') + + +class TracedCableSerializer(serializers.ModelSerializer): + """ + Used only while tracing a cable path. + """ + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') + + class Meta: + model = Cable + fields = [ + 'id', 'url', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'description', + ] + + +class CableTerminationSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cabletermination-detail') + termination_type = ContentTypeField( + queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS) + ) + termination = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = CableTermination + fields = [ + 'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id', 'termination', + 'created', 'last_updated', + ] + + @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) + + class Meta: + model = CablePath + fields = ['id', 'path', 'is_active', 'is_complete', 'is_split'] + + @extend_schema_field(serializers.ListField) + def get_path(self, obj): + ret = [] + for nodes in obj.path_objects: + serializer = get_serializer_for_model(nodes[0]) + context = {'request': self.context['request']} + ret.append(serializer(nodes, nested=True, many=True, context=context).data) + return ret + + +class CabledObjectSerializer(serializers.ModelSerializer): + cable = CableSerializer(nested=True, read_only=True, allow_null=True) + cable_end = serializers.CharField(read_only=True) + link_peers_type = serializers.SerializerMethodField(read_only=True) + link_peers = serializers.SerializerMethodField(read_only=True) + _occupied = serializers.SerializerMethodField(read_only=True) + + @extend_schema_field(OpenApiTypes.STR) + def get_link_peers_type(self, obj): + """ + Return the type of the peer link terminations, or None. + """ + if not obj.cable: + return None + + if obj.link_peers: + return f'{obj.link_peers[0]._meta.app_label}.{obj.link_peers[0]._meta.model_name}' + + return None + + @extend_schema_field(serializers.ListField) + def get_link_peers(self, obj): + """ + Return the appropriate serializer for the link termination model. + """ + if not obj.link_peers: + return [] + + # Return serialized peer termination objects + serializer = get_serializer_for_model(obj.link_peers[0]) + context = {'request': self.context['request']} + return serializer(obj.link_peers, nested=True, many=True, context=context).data + + @extend_schema_field(serializers.BooleanField) + def get__occupied(self, obj): + return obj._occupied diff --git a/netbox/dcim/api/serializers_/device_components.py b/netbox/dcim/api/serializers_/device_components.py new file mode 100644 index 000000000..fd32d95d0 --- /dev/null +++ b/netbox/dcim/api/serializers_/device_components.py @@ -0,0 +1,368 @@ +from django.contrib.contenttypes.models import ContentType +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 ( + ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort, + RearPort, VirtualDeviceContext, +) +from ipam.api.serializers_.vlans import VLANSerializer +from ipam.api.serializers_.vrfs import VRFSerializer +from ipam.models import VLAN +from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField +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.nested_serializers import NestedWirelessLinkSerializer +from wireless.api.serializers_.wirelesslans import WirelessLANSerializer +from wireless.choices import * +from wireless.models import WirelessLAN +from .base import ConnectedEndpointsSerializer +from .cables import CabledObjectSerializer +from .devices import DeviceSerializer, ModuleSerializer, VirtualDeviceContextSerializer +from .manufacturers import ManufacturerSerializer +from .roles import InventoryItemRoleSerializer +from ..nested_serializers import * + +__all__ = ( + 'ConsolePortSerializer', + 'ConsoleServerPortSerializer', + 'DeviceBaySerializer', + 'FrontPortSerializer', + 'InterfaceSerializer', + 'InventoryItemSerializer', + 'ModuleBaySerializer', + 'PowerOutletSerializer', + 'PowerPortSerializer', + 'RearPortSerializer', +) + + +class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') + device = DeviceSerializer(nested=True) + module = ModuleSerializer( + nested=True, + fields=('id', 'url', 'display', 'device', 'module_bay'), + required=False, + allow_null=True + ) + type = ChoiceField( + choices=ConsolePortTypeChoices, + allow_blank=True, + required=False + ) + speed = ChoiceField( + choices=ConsolePortSpeedChoices, + allow_null=True, + required=False + ) + + class Meta: + model = ConsoleServerPort + fields = [ + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description', + 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints', + 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', + 'last_updated', '_occupied', + ] + brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') + + +class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') + device = DeviceSerializer(nested=True) + module = ModuleSerializer( + nested=True, + fields=('id', 'url', 'display', 'device', 'module_bay'), + required=False, + allow_null=True + ) + type = ChoiceField( + choices=ConsolePortTypeChoices, + allow_blank=True, + required=False + ) + speed = ChoiceField( + choices=ConsolePortSpeedChoices, + allow_null=True, + required=False + ) + + class Meta: + model = ConsolePort + fields = [ + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description', + 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints', + 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', + 'last_updated', '_occupied', + ] + brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') + + +class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') + device = DeviceSerializer(nested=True) + module = ModuleSerializer( + nested=True, + fields=('id', 'url', 'display', 'device', 'module_bay'), + required=False, + allow_null=True + ) + type = ChoiceField( + choices=PowerPortTypeChoices, + allow_blank=True, + required=False, + allow_null=True + ) + + class Meta: + model = PowerPort + fields = [ + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', + 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', + 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', + 'created', 'last_updated', '_occupied', + ] + brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') + + +class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') + device = DeviceSerializer(nested=True) + module = ModuleSerializer( + nested=True, + fields=('id', 'url', 'display', 'device', 'module_bay'), + required=False, + allow_null=True + ) + type = ChoiceField( + choices=PowerOutletTypeChoices, + allow_blank=True, + required=False, + allow_null=True + ) + power_port = PowerPortSerializer( + nested=True, + required=False, + allow_null=True + ) + feed_leg = ChoiceField( + choices=PowerOutletFeedLegChoices, + allow_blank=True, + required=False, + allow_null=True + ) + + class Meta: + model = PowerOutlet + fields = [ + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', + 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', + 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', + 'created', 'last_updated', '_occupied', + ] + brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') + + +class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') + device = DeviceSerializer(nested=True) + vdcs = SerializedPKRelatedField( + queryset=VirtualDeviceContext.objects.all(), + serializer=VirtualDeviceContextSerializer, + nested=True, + required=False, + many=True + ) + module = ModuleSerializer( + nested=True, + fields=('id', 'url', 'display', 'device', 'module_bay'), + required=False, + allow_null=True + ) + type = ChoiceField(choices=InterfaceTypeChoices) + parent = NestedInterfaceSerializer(required=False, allow_null=True) + bridge = NestedInterfaceSerializer(required=False, allow_null=True) + lag = NestedInterfaceSerializer(required=False, allow_null=True) + mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True) + duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True, allow_null=True) + rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True) + rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True) + poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True) + poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True) + untagged_vlan = VLANSerializer(nested=True, required=False, allow_null=True) + tagged_vlans = SerializedPKRelatedField( + queryset=VLAN.objects.all(), + serializer=VLANSerializer, + nested=True, + required=False, + many=True + ) + vrf = VRFSerializer(nested=True, required=False, allow_null=True) + l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True) + wireless_link = NestedWirelessLinkSerializer(read_only=True, allow_null=True) + wireless_lans = SerializedPKRelatedField( + queryset=WirelessLAN.objects.all(), + serializer=WirelessLANSerializer, + nested=True, + required=False, + many=True + ) + count_ipaddresses = serializers.IntegerField(read_only=True) + count_fhrp_groups = serializers.IntegerField(read_only=True) + mac_address = serializers.CharField( + required=False, + default=None, + allow_blank=True, + allow_null=True + ) + wwn = serializers.CharField(required=False, default=None, allow_blank=True, allow_null=True) + + class Meta: + model = Interface + fields = [ + 'id', 'url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', + 'lag', 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', + 'rf_channel', 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', + 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers', + 'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints', + 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', + 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', + ] + brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') + + def validate(self, data): + + # Validate many-to-many VLAN assignments + if not self.nested: + device = self.instance.device if self.instance else data.get('device') + for vlan in data.get('tagged_vlans', []): + if vlan.site not in [device.site, None]: + raise serializers.ValidationError({ + 'tagged_vlans': f"VLAN {vlan} must belong to the same site as the interface's parent device, " + f"or it must be global." + }) + + return super().validate(data) + + +class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') + device = DeviceSerializer(nested=True) + module = ModuleSerializer( + nested=True, + fields=('id', 'url', 'display', 'device', 'module_bay'), + required=False, + allow_null=True + ) + type = ChoiceField(choices=PortTypeChoices) + + class Meta: + model = RearPort + fields = [ + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'description', + 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created', + 'last_updated', '_occupied', + ] + brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') + + +class FrontPortRearPortSerializer(WritableNestedSerializer): + """ + NestedRearPortSerializer but with parent device omitted (since front and rear ports must belong to same device) + """ + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') + + class Meta: + model = RearPort + fields = ['id', 'url', 'display', 'name', 'label', 'description'] + + +class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail') + device = DeviceSerializer(nested=True) + module = ModuleSerializer( + nested=True, + fields=('id', 'url', 'display', 'device', 'module_bay'), + required=False, + allow_null=True + ) + type = ChoiceField(choices=PortTypeChoices) + rear_port = FrontPortRearPortSerializer() + + class Meta: + model = FrontPort + fields = [ + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', + 'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', + 'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', + ] + brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') + + +class ModuleBaySerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail') + device = DeviceSerializer(nested=True) + installed_module = ModuleSerializer( + nested=True, + fields=('id', 'url', 'display', 'serial', 'description'), + required=False, + allow_null=True + ) + + class Meta: + model = ModuleBay + fields = [ + 'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags', + 'custom_fields', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description') + + +class DeviceBaySerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') + device = DeviceSerializer(nested=True) + installed_device = DeviceSerializer(nested=True, required=False, allow_null=True) + + class Meta: + model = DeviceBay + fields = [ + 'id', 'url', 'display', 'device', 'name', 'label', 'description', 'installed_device', 'tags', + 'custom_fields', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'device', 'name', 'description') + + +class InventoryItemSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail') + device = DeviceSerializer(nested=True) + parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) + role = InventoryItemRoleSerializer(nested=True, required=False, allow_null=True) + manufacturer = ManufacturerSerializer(nested=True, required=False, allow_null=True, default=None) + component_type = ContentTypeField( + queryset=ContentType.objects.filter(MODULAR_COMPONENT_MODELS), + required=False, + allow_null=True + ) + component = serializers.SerializerMethodField(read_only=True, allow_null=True) + _depth = serializers.IntegerField(source='level', read_only=True) + + class Meta: + model = InventoryItem + fields = [ + 'id', 'url', 'display', 'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', + 'asset_tag', 'discovered', 'description', 'component_type', '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 new file mode 100644 index 000000000..edfac3072 --- /dev/null +++ b/netbox/dcim/api/serializers_/devices.py @@ -0,0 +1,158 @@ +import decimal + +from django.utils.translation import gettext as _ +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from dcim.choices import * +from dcim.models import Device, DeviceBay, Module, VirtualDeviceContext +from extras.api.serializers_.configtemplates import ConfigTemplateSerializer +from ipam.api.serializers_.ip import IPAddressSerializer +from netbox.api.fields import ChoiceField, RelatedObjectCountField +from netbox.api.serializers import NetBoxModelSerializer +from tenancy.api.serializers_.tenants import TenantSerializer +from virtualization.api.serializers_.clusters import ClusterSerializer +from .devicetypes import * +from .platforms import PlatformSerializer +from .racks import RackSerializer +from .roles import DeviceRoleSerializer +from .sites import LocationSerializer, SiteSerializer +from .virtualchassis import VirtualChassisSerializer +from ..nested_serializers import * + +__all__ = ( + 'DeviceSerializer', + 'DeviceWithConfigContextSerializer', + 'ModuleSerializer', + 'VirtualDeviceContextSerializer', +) + + +class DeviceSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') + device_type = DeviceTypeSerializer(nested=True) + role = DeviceRoleSerializer(nested=True) + tenant = TenantSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + platform = PlatformSerializer(nested=True, required=False, allow_null=True) + site = SiteSerializer(nested=True) + location = LocationSerializer(nested=True, required=False, allow_null=True, default=None) + rack = RackSerializer(nested=True, required=False, allow_null=True, default=None) + face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, default=lambda: '') + position = serializers.DecimalField( + max_digits=4, + decimal_places=1, + allow_null=True, + label=_('Position (U)'), + min_value=decimal.Decimal(0.5), + default=None + ) + status = ChoiceField(choices=DeviceStatusChoices, required=False) + airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) + primary_ip = IPAddressSerializer(nested=True, read_only=True, allow_null=True) + primary_ip4 = IPAddressSerializer(nested=True, required=False, allow_null=True) + primary_ip6 = IPAddressSerializer(nested=True, required=False, allow_null=True) + oob_ip = IPAddressSerializer(nested=True, required=False, allow_null=True) + parent_device = serializers.SerializerMethodField() + cluster = ClusterSerializer(nested=True, required=False, allow_null=True) + virtual_chassis = VirtualChassisSerializer(nested=True, required=False, allow_null=True, default=None) + vc_position = serializers.IntegerField(allow_null=True, max_value=255, min_value=0, default=None) + config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None) + + # Counter fields + console_port_count = serializers.IntegerField(read_only=True) + console_server_port_count = serializers.IntegerField(read_only=True) + power_port_count = serializers.IntegerField(read_only=True) + power_outlet_count = serializers.IntegerField(read_only=True) + interface_count = serializers.IntegerField(read_only=True) + front_port_count = serializers.IntegerField(read_only=True) + rear_port_count = serializers.IntegerField(read_only=True) + device_bay_count = serializers.IntegerField(read_only=True) + module_bay_count = serializers.IntegerField(read_only=True) + inventory_item_count = serializers.IntegerField(read_only=True) + + class Meta: + model = Device + fields = [ + 'id', 'url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', + 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow', + 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position', + 'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', + 'created', 'last_updated', 'console_port_count', 'console_server_port_count', 'power_port_count', + 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count', 'device_bay_count', + 'module_bay_count', 'inventory_item_count', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') + + @extend_schema_field(NestedDeviceSerializer) + def get_parent_device(self, obj): + try: + device_bay = obj.parent_bay + except DeviceBay.DoesNotExist: + return None + context = {'request': self.context['request']} + data = NestedDeviceSerializer(instance=device_bay.device, context=context).data + data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data + return data + + +class DeviceWithConfigContextSerializer(DeviceSerializer): + config_context = serializers.SerializerMethodField(read_only=True, allow_null=True) + + class Meta(DeviceSerializer.Meta): + fields = [ + 'id', 'url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', + 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow', + 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position', + 'vc_priority', 'description', 'comments', 'config_template', 'config_context', 'local_context_data', 'tags', + 'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count', + 'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count', + 'device_bay_count', 'module_bay_count', 'inventory_item_count', + ] + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_config_context(self, obj): + return obj.get_config_context() + + +class VirtualDeviceContextSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail') + device = DeviceSerializer(nested=True) + identifier = serializers.IntegerField(allow_null=True, max_value=32767, min_value=0, required=False, default=None) + tenant = TenantSerializer(nested=True, required=False, allow_null=True, default=None) + primary_ip = IPAddressSerializer(nested=True, read_only=True, allow_null=True) + primary_ip4 = IPAddressSerializer(nested=True, required=False, allow_null=True) + primary_ip6 = IPAddressSerializer(nested=True, required=False, allow_null=True) + status = ChoiceField(choices=VirtualDeviceContextStatusChoices) + + # Related object counts + interface_count = RelatedObjectCountField('interfaces') + + class Meta: + model = VirtualDeviceContext + fields = [ + 'id', 'url', 'display', 'name', 'device', 'identifier', 'tenant', 'primary_ip', 'primary_ip4', + 'primary_ip6', 'status', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'interface_count', + ] + brief_fields = ('id', 'url', 'display', 'name', 'identifier', 'device', 'description') + + +class ModuleSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') + device = DeviceSerializer(nested=True) + module_bay = NestedModuleBaySerializer() + module_type = ModuleTypeSerializer(nested=True) + status = ChoiceField(choices=ModuleStatusChoices, required=False) + + class Meta: + model = Module + fields = [ + 'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag', + 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'device', 'module_bay', 'module_type', 'description') diff --git a/netbox/dcim/api/serializers_/devicetype_components.py b/netbox/dcim/api/serializers_/devicetype_components.py new file mode 100644 index 000000000..ca737ce38 --- /dev/null +++ b/netbox/dcim/api/serializers_/devicetype_components.py @@ -0,0 +1,327 @@ +from django.contrib.contenttypes.models import ContentType +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 ( + ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate, + InventoryItemTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, +) +from netbox.api.fields import ChoiceField, ContentTypeField +from netbox.api.serializers import ValidatedModelSerializer +from utilities.api import get_serializer_for_model +from wireless.choices import * +from .devicetypes import DeviceTypeSerializer, ModuleTypeSerializer +from .manufacturers import ManufacturerSerializer +from .roles import InventoryItemRoleSerializer +from ..nested_serializers import * + +__all__ = ( + 'ConsolePortTemplateSerializer', + 'ConsoleServerPortTemplateSerializer', + 'DeviceBayTemplateSerializer', + 'FrontPortTemplateSerializer', + 'InterfaceTemplateSerializer', + 'InventoryItemTemplateSerializer', + 'ModuleBayTemplateSerializer', + 'PowerOutletTemplateSerializer', + 'PowerPortTemplateSerializer', + 'RearPortTemplateSerializer', +) + + +class ConsolePortTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail') + device_type = DeviceTypeSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + module_type = ModuleTypeSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + type = ChoiceField( + choices=ConsolePortTypeChoices, + allow_blank=True, + required=False + ) + + class Meta: + model = ConsolePortTemplate + fields = [ + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created', + 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') + + +class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverporttemplate-detail') + device_type = DeviceTypeSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + module_type = ModuleTypeSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + type = ChoiceField( + choices=ConsolePortTypeChoices, + allow_blank=True, + required=False + ) + + class Meta: + model = ConsoleServerPortTemplate + fields = [ + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created', + 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') + + +class PowerPortTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail') + device_type = DeviceTypeSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + module_type = ModuleTypeSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + type = ChoiceField( + choices=PowerPortTypeChoices, + allow_blank=True, + required=False, + allow_null=True + ) + + class Meta: + model = PowerPortTemplate + fields = [ + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', + 'allocated_draw', 'description', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') + + +class PowerOutletTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlettemplate-detail') + device_type = DeviceTypeSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + module_type = ModuleTypeSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + type = ChoiceField( + choices=PowerOutletTypeChoices, + allow_blank=True, + required=False, + allow_null=True + ) + power_port = PowerPortTemplateSerializer( + nested=True, + required=False, + allow_null=True + ) + feed_leg = ChoiceField( + choices=PowerOutletFeedLegChoices, + allow_blank=True, + required=False, + allow_null=True + ) + + class Meta: + model = PowerOutletTemplate + fields = [ + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', + 'description', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') + + +class InterfaceTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfacetemplate-detail') + device_type = DeviceTypeSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + module_type = ModuleTypeSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + type = ChoiceField(choices=InterfaceTypeChoices) + bridge = NestedInterfaceTemplateSerializer( + required=False, + allow_null=True + ) + poe_mode = ChoiceField( + choices=InterfacePoEModeChoices, + required=False, + allow_blank=True, + allow_null=True + ) + poe_type = ChoiceField( + choices=InterfacePoETypeChoices, + required=False, + allow_blank=True, + allow_null=True + ) + rf_role = ChoiceField( + choices=WirelessRoleChoices, + required=False, + allow_blank=True, + allow_null=True + ) + + class Meta: + model = InterfaceTemplate + fields = [ + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', + 'description', 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') + + +class RearPortTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail') + device_type = DeviceTypeSerializer( + required=False, + nested=True, + allow_null=True, + default=None + ) + module_type = ModuleTypeSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + type = ChoiceField(choices=PortTypeChoices) + + class Meta: + model = RearPortTemplate + fields = [ + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', + 'description', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') + + +class FrontPortTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail') + device_type = DeviceTypeSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + module_type = ModuleTypeSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + type = ChoiceField(choices=PortTypeChoices) + rear_port = RearPortTemplateSerializer(nested=True) + + class Meta: + model = FrontPortTemplate + fields = [ + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', + 'rear_port_position', 'description', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') + + +class ModuleBayTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebaytemplate-detail') + device_type = DeviceTypeSerializer( + nested=True + ) + + class Meta: + model = ModuleBayTemplate + fields = [ + 'id', 'url', 'display', 'device_type', 'name', 'label', 'position', 'description', 'created', + 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') + + +class DeviceBayTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail') + device_type = DeviceTypeSerializer( + nested=True + ) + + class Meta: + model = DeviceBayTemplate + fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated'] + brief_fields = ('id', 'url', 'display', 'name', 'description') + + +class InventoryItemTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemtemplate-detail') + device_type = DeviceTypeSerializer( + nested=True + ) + parent = serializers.PrimaryKeyRelatedField( + queryset=InventoryItemTemplate.objects.all(), + allow_null=True, + default=None + ) + role = InventoryItemRoleSerializer(nested=True, required=False, allow_null=True) + manufacturer = ManufacturerSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + component_type = ContentTypeField( + queryset=ContentType.objects.filter(MODULAR_COMPONENT_TEMPLATE_MODELS), + required=False, + allow_null=True + ) + component = serializers.SerializerMethodField(read_only=True, allow_null=True) + _depth = serializers.IntegerField(source='level', read_only=True) + + class Meta: + model = InventoryItemTemplate + fields = [ + 'id', 'url', 'display', 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', + 'description', 'component_type', 'component_id', 'component', 'created', 'last_updated', '_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/dcim/api/serializers_/devicetypes.py b/netbox/dcim/api/serializers_/devicetypes.py new file mode 100644 index 000000000..a5830fa90 --- /dev/null +++ b/netbox/dcim/api/serializers_/devicetypes.py @@ -0,0 +1,76 @@ +import decimal + +from django.utils.translation import gettext as _ +from rest_framework import serializers + +from dcim.choices import * +from dcim.models import DeviceType, ModuleType +from netbox.api.fields import ChoiceField, RelatedObjectCountField +from netbox.api.serializers import NetBoxModelSerializer +from .manufacturers import ManufacturerSerializer +from .platforms import PlatformSerializer + +__all__ = ( + 'DeviceTypeSerializer', + 'ModuleTypeSerializer', +) + + +class DeviceTypeSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') + manufacturer = ManufacturerSerializer(nested=True) + default_platform = PlatformSerializer(nested=True, required=False, allow_null=True) + u_height = serializers.DecimalField( + max_digits=4, + decimal_places=1, + label=_('Position (U)'), + min_value=decimal.Decimal(0), + default=1.0 + ) + subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False, allow_null=True) + airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False, allow_null=True) + weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True) + front_image = serializers.ImageField(required=False, allow_null=True) + rear_image = serializers.ImageField(required=False, allow_null=True) + + # Counter fields + console_port_template_count = serializers.IntegerField(read_only=True) + console_server_port_template_count = serializers.IntegerField(read_only=True) + power_port_template_count = serializers.IntegerField(read_only=True) + power_outlet_template_count = serializers.IntegerField(read_only=True) + interface_template_count = serializers.IntegerField(read_only=True) + front_port_template_count = serializers.IntegerField(read_only=True) + rear_port_template_count = serializers.IntegerField(read_only=True) + 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') + + class Meta: + model = DeviceType + fields = [ + 'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', + 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', + 'front_image', 'rear_image', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'device_count', 'console_port_template_count', 'console_server_port_template_count', + 'power_port_template_count', 'power_outlet_template_count', 'interface_template_count', + 'front_port_template_count', 'rear_port_template_count', 'device_bay_template_count', + 'module_bay_template_count', 'inventory_item_template_count', + ] + brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count') + + +class ModuleTypeSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail') + manufacturer = ManufacturerSerializer(nested=True) + weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True) + + class Meta: + model = ModuleType + fields = [ + 'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'description', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'description') diff --git a/netbox/dcim/api/serializers_/manufacturers.py b/netbox/dcim/api/serializers_/manufacturers.py new file mode 100644 index 000000000..fd50fe97d --- /dev/null +++ b/netbox/dcim/api/serializers_/manufacturers.py @@ -0,0 +1,26 @@ +from rest_framework import serializers + +from dcim.models import Manufacturer +from netbox.api.fields import RelatedObjectCountField +from netbox.api.serializers import NetBoxModelSerializer + +__all__ = ( + 'ManufacturerSerializer', +) + + +class ManufacturerSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') + + # Related object counts + devicetype_count = RelatedObjectCountField('device_types') + inventoryitem_count = RelatedObjectCountField('inventory_items') + platform_count = RelatedObjectCountField('platforms') + + class Meta: + model = Manufacturer + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated', + 'devicetype_count', 'inventoryitem_count', 'platform_count', + ] + brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'devicetype_count') diff --git a/netbox/dcim/api/serializers_/platforms.py b/netbox/dcim/api/serializers_/platforms.py new file mode 100644 index 000000000..7365404eb --- /dev/null +++ b/netbox/dcim/api/serializers_/platforms.py @@ -0,0 +1,29 @@ +from rest_framework import serializers + +from dcim.models import Platform +from extras.api.serializers_.configtemplates import ConfigTemplateSerializer +from netbox.api.fields import RelatedObjectCountField +from netbox.api.serializers import NetBoxModelSerializer +from .manufacturers import ManufacturerSerializer + +__all__ = ( + 'PlatformSerializer', +) + + +class PlatformSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') + manufacturer = ManufacturerSerializer(nested=True, required=False, allow_null=True) + config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None) + + # Related object counts + device_count = RelatedObjectCountField('devices') + virtualmachine_count = RelatedObjectCountField('virtual_machines') + + class Meta: + model = Platform + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags', + 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', + ] + brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count') diff --git a/netbox/dcim/api/serializers_/power.py b/netbox/dcim/api/serializers_/power.py new file mode 100644 index 000000000..dddd54906 --- /dev/null +++ b/netbox/dcim/api/serializers_/power.py @@ -0,0 +1,80 @@ +from rest_framework import serializers + +from dcim.choices import * +from dcim.models import PowerFeed, PowerPanel +from netbox.api.fields import ChoiceField, RelatedObjectCountField +from netbox.api.serializers import NetBoxModelSerializer +from tenancy.api.serializers_.tenants import TenantSerializer +from .base import ConnectedEndpointsSerializer +from .cables import CabledObjectSerializer +from .racks import RackSerializer +from .sites import LocationSerializer, SiteSerializer + +__all__ = ( + 'PowerFeedSerializer', + 'PowerPanelSerializer', +) + + +class PowerPanelSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail') + site = SiteSerializer(nested=True) + location = LocationSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + + # Related object counts + powerfeed_count = RelatedObjectCountField('powerfeeds') + + class Meta: + model = PowerPanel + fields = [ + 'id', 'url', 'display', 'site', 'location', 'name', 'description', 'comments', 'tags', 'custom_fields', + 'powerfeed_count', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description', 'powerfeed_count') + + +class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail') + power_panel = PowerPanelSerializer(nested=True) + rack = RackSerializer( + nested=True, + required=False, + allow_null=True, + default=None + ) + type = ChoiceField( + choices=PowerFeedTypeChoices, + default=lambda: PowerFeedTypeChoices.TYPE_PRIMARY, + ) + status = ChoiceField( + choices=PowerFeedStatusChoices, + default=lambda: PowerFeedStatusChoices.STATUS_ACTIVE, + ) + supply = ChoiceField( + choices=PowerFeedSupplyChoices, + default=lambda: PowerFeedSupplyChoices.SUPPLY_AC, + ) + phase = ChoiceField( + choices=PowerFeedPhaseChoices, + default=lambda: PowerFeedPhaseChoices.PHASE_SINGLE, + ) + tenant = TenantSerializer( + nested=True, + required=False, + allow_null=True + ) + + class Meta: + model = PowerFeed + fields = [ + 'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', + 'amperage', 'max_utilization', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', + 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'description', + 'tenant', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description', 'cable', '_occupied') diff --git a/netbox/dcim/api/serializers_/racks.py b/netbox/dcim/api/serializers_/racks.py new file mode 100644 index 000000000..a6754cba0 --- /dev/null +++ b/netbox/dcim/api/serializers_/racks.py @@ -0,0 +1,117 @@ +from django.utils.translation import gettext as _ +from rest_framework import serializers + +from dcim.choices import * +from dcim.constants import * +from dcim.models import Rack, RackReservation, RackRole +from netbox.api.fields import ChoiceField, RelatedObjectCountField +from netbox.api.serializers import NetBoxModelSerializer +from netbox.config import ConfigItem +from tenancy.api.serializers_.tenants import TenantSerializer +from users.api.serializers_.users import UserSerializer +from .sites import LocationSerializer, SiteSerializer + +__all__ = ( + 'RackElevationDetailFilterSerializer', + 'RackReservationSerializer', + 'RackRoleSerializer', + 'RackSerializer', +) + + +class RackRoleSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') + + # Related object counts + rack_count = RelatedObjectCountField('racks') + + class Meta: + model = RackRole + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'rack_count', + ] + brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count') + + +class RackSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail') + site = SiteSerializer(nested=True) + location = LocationSerializer(nested=True, required=False, allow_null=True, default=None) + tenant = TenantSerializer(nested=True, required=False, allow_null=True) + status = ChoiceField(choices=RackStatusChoices, required=False) + role = RackRoleSerializer(nested=True, required=False, allow_null=True) + type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False, allow_null=True) + facility_id = serializers.CharField(max_length=50, allow_blank=True, allow_null=True, label=_('Facility ID'), + default=None) + width = ChoiceField(choices=RackWidthChoices, required=False) + outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False, allow_null=True) + weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True) + + # Related object counts + device_count = RelatedObjectCountField('devices') + powerfeed_count = RelatedObjectCountField('powerfeeds') + + class Meta: + model = Rack + fields = [ + 'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial', + 'asset_tag', 'type', 'width', 'u_height', 'starting_unit', 'weight', 'max_weight', 'weight_unit', + 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments', + 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description', 'device_count') + + +class RackReservationSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail') + rack = RackSerializer(nested=True) + user = UserSerializer(nested=True) + tenant = TenantSerializer(nested=True, required=False, allow_null=True) + + class Meta: + model = RackReservation + fields = [ + 'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description', + 'comments', 'tags', 'custom_fields', + ] + brief_fields = ('id', 'url', 'display', 'user', 'description', 'units') + + +class RackElevationDetailFilterSerializer(serializers.Serializer): + q = serializers.CharField( + required=False, + default=None + ) + face = serializers.ChoiceField( + choices=DeviceFaceChoices, + default=DeviceFaceChoices.FACE_FRONT + ) + render = serializers.ChoiceField( + choices=RackElevationDetailRenderChoices, + default=RackElevationDetailRenderChoices.RENDER_JSON + ) + unit_width = serializers.IntegerField( + default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_WIDTH') + ) + unit_height = serializers.IntegerField( + default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT') + ) + legend_width = serializers.IntegerField( + default=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH + ) + margin_width = serializers.IntegerField( + default=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH + ) + exclude = serializers.IntegerField( + required=False, + default=None + ) + expand_devices = serializers.BooleanField( + required=False, + default=True + ) + include_images = serializers.BooleanField( + required=False, + default=True + ) diff --git a/netbox/dcim/api/serializers_/rackunits.py b/netbox/dcim/api/serializers_/rackunits.py new file mode 100644 index 000000000..1f5306718 --- /dev/null +++ b/netbox/dcim/api/serializers_/rackunits.py @@ -0,0 +1,31 @@ +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from dcim.choices import * +from netbox.api.fields import ChoiceField +from .devices import DeviceSerializer + +__all__ = ( + 'RackUnitSerializer', +) + + +class RackUnitSerializer(serializers.Serializer): + """ + A rack unit is an abstraction formed by the set (rack, position, face); it does not exist as a row in the database. + """ + id = serializers.DecimalField( + max_digits=4, + decimal_places=1, + read_only=True + ) + name = serializers.CharField(read_only=True) + face = ChoiceField(choices=DeviceFaceChoices, read_only=True) + device = DeviceSerializer(nested=True, read_only=True) + occupied = serializers.BooleanField(read_only=True) + display = serializers.SerializerMethodField(read_only=True) + + @extend_schema_field(OpenApiTypes.STR) + def get_display(self, obj): + return obj['name'] diff --git a/netbox/dcim/api/serializers_/roles.py b/netbox/dcim/api/serializers_/roles.py new file mode 100644 index 000000000..41f8f377d --- /dev/null +++ b/netbox/dcim/api/serializers_/roles.py @@ -0,0 +1,43 @@ +from rest_framework import serializers + +from dcim.models import DeviceRole, InventoryItemRole +from extras.api.serializers_.configtemplates import ConfigTemplateSerializer +from netbox.api.fields import RelatedObjectCountField +from netbox.api.serializers import NetBoxModelSerializer + +__all__ = ( + 'DeviceRoleSerializer', + 'InventoryItemRoleSerializer', +) + + +class DeviceRoleSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') + config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None) + + # Related object counts + device_count = RelatedObjectCountField('devices') + virtualmachine_count = RelatedObjectCountField('virtual_machines') + + class Meta: + model = DeviceRole + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags', + 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', + ] + brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count') + + +class InventoryItemRoleSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail') + + # Related object counts + inventoryitem_count = RelatedObjectCountField('inventory_items') + + class Meta: + model = InventoryItemRole + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'inventoryitem_count', + ] + brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'inventoryitem_count') diff --git a/netbox/dcim/api/serializers_/sites.py b/netbox/dcim/api/serializers_/sites.py new file mode 100644 index 000000000..60e1477e5 --- /dev/null +++ b/netbox/dcim/api/serializers_/sites.py @@ -0,0 +1,98 @@ +from rest_framework import serializers +from timezone_field.rest_framework import TimeZoneSerializerField + +from dcim.choices import * +from dcim.models import Location, Region, Site, SiteGroup +from ipam.api.serializers_.asns import ASNSerializer +from ipam.models import ASN +from netbox.api.fields import ChoiceField, RelatedObjectCountField, SerializedPKRelatedField +from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer +from tenancy.api.serializers_.tenants import TenantSerializer +from ..nested_serializers import * + +__all__ = ( + 'LocationSerializer', + 'RegionSerializer', + 'SiteGroupSerializer', + 'SiteSerializer', +) + + +class RegionSerializer(NestedGroupModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail') + parent = NestedRegionSerializer(required=False, allow_null=True, default=None) + site_count = serializers.IntegerField(read_only=True, default=0) + + class Meta: + model = Region + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'site_count', '_depth', + ] + brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth') + + +class SiteGroupSerializer(NestedGroupModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail') + parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None) + site_count = serializers.IntegerField(read_only=True, default=0) + + class Meta: + model = SiteGroup + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'site_count', '_depth', + ] + brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth') + + +class SiteSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail') + status = ChoiceField(choices=SiteStatusChoices, required=False) + region = RegionSerializer(nested=True, required=False, allow_null=True) + group = SiteGroupSerializer(nested=True, required=False, allow_null=True) + tenant = TenantSerializer(nested=True, required=False, allow_null=True) + time_zone = TimeZoneSerializerField(required=False, allow_null=True) + asns = SerializedPKRelatedField( + queryset=ASN.objects.all(), + serializer=ASNSerializer, + nested=True, + required=False, + many=True + ) + + # Related object counts + circuit_count = RelatedObjectCountField('circuit_terminations') + device_count = RelatedObjectCountField('devices') + prefix_count = RelatedObjectCountField('prefixes') + rack_count = RelatedObjectCountField('racks') + vlan_count = RelatedObjectCountField('vlans') + virtualmachine_count = RelatedObjectCountField('virtual_machines') + + class Meta: + model = Site + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', + 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'asns', 'tags', + 'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'prefix_count', 'rack_count', + 'virtualmachine_count', 'vlan_count', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description', 'slug') + + +class LocationSerializer(NestedGroupModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail') + site = SiteSerializer(nested=True) + parent = NestedLocationSerializer(required=False, allow_null=True, default=None) + status = ChoiceField(choices=LocationStatusChoices, required=False) + tenant = TenantSerializer(nested=True, required=False, allow_null=True) + rack_count = serializers.IntegerField(read_only=True, default=0) + device_count = serializers.IntegerField(read_only=True, default=0) + + class Meta: + model = Location + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'facility', 'description', + 'tags', 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth', + ] + brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count', '_depth') diff --git a/netbox/dcim/api/serializers_/virtualchassis.py b/netbox/dcim/api/serializers_/virtualchassis.py new file mode 100644 index 000000000..5a5917119 --- /dev/null +++ b/netbox/dcim/api/serializers_/virtualchassis.py @@ -0,0 +1,26 @@ +from rest_framework import serializers + +from dcim.models import VirtualChassis +from netbox.api.serializers import NetBoxModelSerializer +from ..nested_serializers import * + +__all__ = ( + 'VirtualChassisSerializer', +) + + +class VirtualChassisSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') + master = NestedDeviceSerializer(required=False, allow_null=True, default=None) + members = NestedDeviceSerializer(many=True, read_only=True) + + # Counter fields + member_count = serializers.IntegerField(read_only=True) + + class Meta: + model = VirtualChassis + fields = [ + 'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', 'member_count', 'members', + ] + brief_fields = ('id', 'url', 'display', 'name', 'master', 'description', 'member_count') diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index bec52d97d..d6ddd466b 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -7,23 +7,18 @@ from rest_framework.response import Response from rest_framework.routers import APIRootView from rest_framework.viewsets import ViewSet -from circuits.models import Circuit from dcim import filtersets from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH from dcim.models import * from dcim.svg import CableTraceSVG from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin -from ipam.models import Prefix, VLAN 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.mixins import SequentialBulkCreatesMixin -from netbox.constants import NESTED_SERIALIZER_PREFIX from utilities.api import get_serializer_for_model from utilities.query_functions import CollateAsChar -from utilities.utils import count_related -from virtualization.models import VirtualMachine from . import serializers from .exceptions import MissingFilterException @@ -62,16 +57,16 @@ class PathEndpointMixin(object): # Serialize path objects, iterating over each three-tuple in the path for near_ends, cable, far_ends in obj.trace(): if near_ends: - serializer_a = get_serializer_for_model(near_ends[0], prefix=NESTED_SERIALIZER_PREFIX) - near_ends = serializer_a(near_ends, many=True, context={'request': request}).data + serializer_a = get_serializer_for_model(near_ends[0]) + near_ends = serializer_a(near_ends, nested=True, many=True, context={'request': request}).data else: # Path is split; stop here break if cable: cable = serializers.TracedCableSerializer(cable[0], context={'request': request}).data if far_ends: - serializer_b = get_serializer_for_model(far_ends[0], prefix=NESTED_SERIALIZER_PREFIX) - far_ends = serializer_b(far_ends, many=True, context={'request': request}).data + serializer_b = get_serializer_for_model(far_ends[0]) + far_ends = serializer_b(far_ends, nested=True, many=True, context={'request': request}).data path.append((near_ends, cable, far_ends)) @@ -103,7 +98,7 @@ class RegionViewSet(MPTTLockedMixin, NetBoxModelViewSet): 'region', 'site_count', cumulative=True - ).prefetch_related('tags') + ) serializer_class = serializers.RegionSerializer filterset_class = filtersets.RegionFilterSet @@ -119,7 +114,7 @@ class SiteGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet): 'group', 'site_count', cumulative=True - ).prefetch_related('tags') + ) serializer_class = serializers.SiteGroupSerializer filterset_class = filtersets.SiteGroupFilterSet @@ -129,16 +124,7 @@ class SiteGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet): # class SiteViewSet(NetBoxModelViewSet): - queryset = Site.objects.prefetch_related( - 'region', 'tenant', 'asns', 'tags' - ).annotate( - device_count=count_related(Device, 'site'), - rack_count=count_related(Rack, 'site'), - prefix_count=count_related(Prefix, 'site'), - vlan_count=count_related(VLAN, 'site'), - circuit_count=count_related(Circuit, 'terminations__site'), - virtualmachine_count=count_related(VirtualMachine, 'cluster__site') - ) + queryset = Site.objects.all() serializer_class = serializers.SiteSerializer filterset_class = filtersets.SiteFilterSet @@ -160,7 +146,7 @@ class LocationViewSet(MPTTLockedMixin, NetBoxModelViewSet): 'location', 'rack_count', cumulative=True - ).prefetch_related('site', 'tags') + ) serializer_class = serializers.LocationSerializer filterset_class = filtersets.LocationFilterSet @@ -170,9 +156,7 @@ class LocationViewSet(MPTTLockedMixin, NetBoxModelViewSet): # class RackRoleViewSet(NetBoxModelViewSet): - queryset = RackRole.objects.prefetch_related('tags').annotate( - rack_count=count_related(Rack, 'role') - ) + queryset = RackRole.objects.all() serializer_class = serializers.RackRoleSerializer filterset_class = filtersets.RackRoleFilterSet @@ -182,12 +166,7 @@ class RackRoleViewSet(NetBoxModelViewSet): # class RackViewSet(NetBoxModelViewSet): - queryset = Rack.objects.prefetch_related( - 'site', 'location', 'role', 'tenant', 'tags' - ).annotate( - device_count=count_related(Device, 'rack'), - powerfeed_count=count_related(PowerFeed, 'rack') - ) + queryset = Rack.objects.all() serializer_class = serializers.RackSerializer filterset_class = filtersets.RackFilterSet @@ -255,7 +234,7 @@ class RackViewSet(NetBoxModelViewSet): # class RackReservationViewSet(NetBoxModelViewSet): - queryset = RackReservation.objects.prefetch_related('rack', 'user', 'tenant') + queryset = RackReservation.objects.all() serializer_class = serializers.RackReservationSerializer filterset_class = filtersets.RackReservationFilterSet @@ -265,11 +244,7 @@ class RackReservationViewSet(NetBoxModelViewSet): # class ManufacturerViewSet(NetBoxModelViewSet): - queryset = Manufacturer.objects.prefetch_related('tags').annotate( - devicetype_count=count_related(DeviceType, 'manufacturer'), - inventoryitem_count=count_related(InventoryItem, 'manufacturer'), - platform_count=count_related(Platform, 'manufacturer') - ) + queryset = Manufacturer.objects.all() serializer_class = serializers.ManufacturerSerializer filterset_class = filtersets.ManufacturerFilterSet @@ -279,21 +254,15 @@ class ManufacturerViewSet(NetBoxModelViewSet): # class DeviceTypeViewSet(NetBoxModelViewSet): - queryset = DeviceType.objects.prefetch_related('manufacturer', 'default_platform', 'tags').annotate( - device_count=count_related(Device, 'device_type') - ) + queryset = DeviceType.objects.all() serializer_class = serializers.DeviceTypeSerializer filterset_class = filtersets.DeviceTypeFilterSet - brief_prefetch_fields = ['manufacturer'] class ModuleTypeViewSet(NetBoxModelViewSet): - queryset = ModuleType.objects.prefetch_related('manufacturer', 'tags').annotate( - # module_count=count_related(Module, 'module_type') - ) + queryset = ModuleType.objects.all() serializer_class = serializers.ModuleTypeSerializer filterset_class = filtersets.ModuleTypeFilterSet - brief_prefetch_fields = ['manufacturer'] # @@ -301,61 +270,61 @@ class ModuleTypeViewSet(NetBoxModelViewSet): # class ConsolePortTemplateViewSet(NetBoxModelViewSet): - queryset = ConsolePortTemplate.objects.prefetch_related('device_type__manufacturer') + queryset = ConsolePortTemplate.objects.all() serializer_class = serializers.ConsolePortTemplateSerializer filterset_class = filtersets.ConsolePortTemplateFilterSet class ConsoleServerPortTemplateViewSet(NetBoxModelViewSet): - queryset = ConsoleServerPortTemplate.objects.prefetch_related('device_type__manufacturer') + queryset = ConsoleServerPortTemplate.objects.all() serializer_class = serializers.ConsoleServerPortTemplateSerializer filterset_class = filtersets.ConsoleServerPortTemplateFilterSet class PowerPortTemplateViewSet(NetBoxModelViewSet): - queryset = PowerPortTemplate.objects.prefetch_related('device_type__manufacturer') + queryset = PowerPortTemplate.objects.all() serializer_class = serializers.PowerPortTemplateSerializer filterset_class = filtersets.PowerPortTemplateFilterSet class PowerOutletTemplateViewSet(NetBoxModelViewSet): - queryset = PowerOutletTemplate.objects.prefetch_related('device_type__manufacturer') + queryset = PowerOutletTemplate.objects.all() serializer_class = serializers.PowerOutletTemplateSerializer filterset_class = filtersets.PowerOutletTemplateFilterSet class InterfaceTemplateViewSet(NetBoxModelViewSet): - queryset = InterfaceTemplate.objects.prefetch_related('device_type__manufacturer') + queryset = InterfaceTemplate.objects.all() serializer_class = serializers.InterfaceTemplateSerializer filterset_class = filtersets.InterfaceTemplateFilterSet class FrontPortTemplateViewSet(NetBoxModelViewSet): - queryset = FrontPortTemplate.objects.prefetch_related('device_type__manufacturer') + queryset = FrontPortTemplate.objects.all() serializer_class = serializers.FrontPortTemplateSerializer filterset_class = filtersets.FrontPortTemplateFilterSet class RearPortTemplateViewSet(NetBoxModelViewSet): - queryset = RearPortTemplate.objects.prefetch_related('device_type__manufacturer') + queryset = RearPortTemplate.objects.all() serializer_class = serializers.RearPortTemplateSerializer filterset_class = filtersets.RearPortTemplateFilterSet class ModuleBayTemplateViewSet(NetBoxModelViewSet): - queryset = ModuleBayTemplate.objects.prefetch_related('device_type__manufacturer') + queryset = ModuleBayTemplate.objects.all() serializer_class = serializers.ModuleBayTemplateSerializer filterset_class = filtersets.ModuleBayTemplateFilterSet class DeviceBayTemplateViewSet(NetBoxModelViewSet): - queryset = DeviceBayTemplate.objects.prefetch_related('device_type__manufacturer') + queryset = DeviceBayTemplate.objects.all() serializer_class = serializers.DeviceBayTemplateSerializer filterset_class = filtersets.DeviceBayTemplateFilterSet class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet): - queryset = InventoryItemTemplate.objects.prefetch_related('device_type__manufacturer', 'role') + queryset = InventoryItemTemplate.objects.all() serializer_class = serializers.InventoryItemTemplateSerializer filterset_class = filtersets.InventoryItemTemplateFilterSet @@ -365,10 +334,7 @@ class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet): # class DeviceRoleViewSet(NetBoxModelViewSet): - queryset = DeviceRole.objects.prefetch_related('config_template', 'tags').annotate( - device_count=count_related(Device, 'role'), - virtualmachine_count=count_related(VirtualMachine, 'role') - ) + queryset = DeviceRole.objects.all() serializer_class = serializers.DeviceRoleSerializer filterset_class = filtersets.DeviceRoleFilterSet @@ -378,10 +344,7 @@ class DeviceRoleViewSet(NetBoxModelViewSet): # class PlatformViewSet(NetBoxModelViewSet): - queryset = Platform.objects.prefetch_related('config_template', 'tags').annotate( - device_count=count_related(Device, 'platform'), - virtualmachine_count=count_related(VirtualMachine, 'platform') - ) + queryset = Platform.objects.all() serializer_class = serializers.PlatformSerializer filterset_class = filtersets.PlatformFilterSet @@ -397,8 +360,7 @@ class DeviceViewSet( NetBoxModelViewSet ): queryset = Device.objects.prefetch_related( - 'device_type__manufacturer', 'role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay', - 'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'config_template', 'tags', + 'parent_bay', # Referenced by DeviceSerializer.get_parent_device() ) filterset_class = filtersets.DeviceFilterSet pagination_class = StripCountAnnotationsPaginator @@ -413,31 +375,21 @@ class DeviceViewSet( Else, return the DeviceWithConfigContextSerializer """ - request = self.get_serializer_context()['request'] - if request.query_params.get('brief', False): - return serializers.NestedDeviceSerializer - - elif 'config_context' in request.query_params.get('exclude', []): + if self.brief or 'config_context' in request.query_params.get('exclude', []): return serializers.DeviceSerializer return serializers.DeviceWithConfigContextSerializer class VirtualDeviceContextViewSet(NetBoxModelViewSet): - queryset = VirtualDeviceContext.objects.prefetch_related( - 'device__device_type', 'device', 'tenant', 'tags', - ).annotate( - interface_count=count_related(Interface, 'vdcs'), - ) + queryset = VirtualDeviceContext.objects.all() serializer_class = serializers.VirtualDeviceContextSerializer filterset_class = filtersets.VirtualDeviceContextFilterSet class ModuleViewSet(NetBoxModelViewSet): - queryset = Module.objects.prefetch_related( - 'device', 'module_bay', 'module_type__manufacturer', 'tags', - ) + queryset = Module.objects.all() serializer_class = serializers.ModuleSerializer filterset_class = filtersets.ModuleFilterSet @@ -448,49 +400,45 @@ class ModuleViewSet(NetBoxModelViewSet): class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = ConsolePort.objects.prefetch_related( - 'device', 'module__module_bay', '_path', 'cable__terminations', 'tags' + '_path', 'cable__terminations', ) serializer_class = serializers.ConsolePortSerializer filterset_class = filtersets.ConsolePortFilterSet - brief_prefetch_fields = ['device'] class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = ConsoleServerPort.objects.prefetch_related( - 'device', 'module__module_bay', '_path', 'cable__terminations', 'tags' + '_path', 'cable__terminations', ) serializer_class = serializers.ConsoleServerPortSerializer filterset_class = filtersets.ConsoleServerPortFilterSet - brief_prefetch_fields = ['device'] class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = PowerPort.objects.prefetch_related( - 'device', 'module__module_bay', '_path', 'cable__terminations', 'tags' + '_path', 'cable__terminations', ) serializer_class = serializers.PowerPortSerializer filterset_class = filtersets.PowerPortFilterSet - brief_prefetch_fields = ['device'] class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = PowerOutlet.objects.prefetch_related( - 'device', 'module__module_bay', '_path', 'cable__terminations', 'tags' + '_path', 'cable__terminations', ) serializer_class = serializers.PowerOutletSerializer filterset_class = filtersets.PowerOutletFilterSet - brief_prefetch_fields = ['device'] class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = Interface.objects.prefetch_related( - 'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path', 'cable__terminations', 'wireless_lans', - 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags', 'l2vpn_terminations', - 'vdcs', + '_path', 'cable__terminations', + 'l2vpn_terminations', # Referenced by InterfaceSerializer.l2vpn_termination + 'ip_addresses', # Referenced by Interface.count_ipaddresses() + 'fhrp_group_assignments', # Referenced by Interface.count_fhrp_groups() ) serializer_class = serializers.InterfaceSerializer filterset_class = filtersets.InterfaceFilterSet - brief_prefetch_fields = ['device'] def get_bulk_destroy_queryset(self): # Ensure child interfaces are deleted prior to their parents @@ -499,41 +447,36 @@ class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet): class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet): queryset = FrontPort.objects.prefetch_related( - 'device__device_type__manufacturer', 'module__module_bay', 'rear_port', 'cable__terminations', 'tags' + 'cable__terminations', ) serializer_class = serializers.FrontPortSerializer filterset_class = filtersets.FrontPortFilterSet - brief_prefetch_fields = ['device'] class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet): queryset = RearPort.objects.prefetch_related( - 'device__device_type__manufacturer', 'module__module_bay', 'cable__terminations', 'tags' + 'cable__terminations', ) serializer_class = serializers.RearPortSerializer filterset_class = filtersets.RearPortFilterSet - brief_prefetch_fields = ['device'] class ModuleBayViewSet(NetBoxModelViewSet): - queryset = ModuleBay.objects.prefetch_related('tags', 'installed_module') + queryset = ModuleBay.objects.all() serializer_class = serializers.ModuleBaySerializer filterset_class = filtersets.ModuleBayFilterSet - brief_prefetch_fields = ['device'] class DeviceBayViewSet(NetBoxModelViewSet): - queryset = DeviceBay.objects.prefetch_related('installed_device', 'tags') + queryset = DeviceBay.objects.all() serializer_class = serializers.DeviceBaySerializer filterset_class = filtersets.DeviceBayFilterSet - brief_prefetch_fields = ['device'] class InventoryItemViewSet(MPTTLockedMixin, NetBoxModelViewSet): - queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'tags') + queryset = InventoryItem.objects.all() serializer_class = serializers.InventoryItemSerializer filterset_class = filtersets.InventoryItemFilterSet - brief_prefetch_fields = ['device'] # @@ -541,9 +484,7 @@ class InventoryItemViewSet(MPTTLockedMixin, NetBoxModelViewSet): # class InventoryItemRoleViewSet(NetBoxModelViewSet): - queryset = InventoryItemRole.objects.prefetch_related('tags').annotate( - inventoryitem_count=count_related(InventoryItem, 'role') - ) + queryset = InventoryItemRole.objects.all() serializer_class = serializers.InventoryItemRoleSerializer filterset_class = filtersets.InventoryItemRoleFilterSet @@ -560,7 +501,7 @@ class CableViewSet(NetBoxModelViewSet): class CableTerminationViewSet(NetBoxModelViewSet): metadata_class = ContentTypeMetadata - queryset = CableTermination.objects.prefetch_related('cable', 'termination') + queryset = CableTermination.objects.all() serializer_class = serializers.CableTerminationSerializer filterset_class = filtersets.CableTerminationFilterSet @@ -570,10 +511,12 @@ class CableTerminationViewSet(NetBoxModelViewSet): # class VirtualChassisViewSet(NetBoxModelViewSet): - queryset = VirtualChassis.objects.prefetch_related('tags') + queryset = VirtualChassis.objects.prefetch_related( + # Prefetch related object for the display of unnamed devices + 'master__virtual_chassis', + ) serializer_class = serializers.VirtualChassisSerializer filterset_class = filtersets.VirtualChassisFilterSet - brief_prefetch_fields = ['master'] # @@ -581,11 +524,7 @@ class VirtualChassisViewSet(NetBoxModelViewSet): # class PowerPanelViewSet(NetBoxModelViewSet): - queryset = PowerPanel.objects.prefetch_related( - 'site', 'location' - ).annotate( - powerfeed_count=count_related(PowerFeed, 'power_panel') - ) + queryset = PowerPanel.objects.all() serializer_class = serializers.PowerPanelSerializer filterset_class = filtersets.PowerPanelFilterSet @@ -596,7 +535,7 @@ class PowerPanelViewSet(NetBoxModelViewSet): class PowerFeedViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = PowerFeed.objects.prefetch_related( - 'power_panel', 'rack', '_path', 'cable__terminations', 'tags' + '_path', 'cable__terminations', ) serializer_class = serializers.PowerFeedSerializer filterset_class = filtersets.PowerFeedFilterSet diff --git a/netbox/dcim/apps.py b/netbox/dcim/apps.py index 78ff0d4c1..4df66e367 100644 --- a/netbox/dcim/apps.py +++ b/netbox/dcim/apps.py @@ -8,9 +8,13 @@ class DCIMConfig(AppConfig): verbose_name = "DCIM" def ready(self): + from netbox.models.features import register_models + from utilities.counters import connect_counters from . import signals, search from .models import CableTermination, Device, DeviceType, VirtualChassis - from utilities.counters import connect_counters + + # Register models + register_models(*self.get_models()) # Register denormalized fields denormalized.register(CableTermination, '_device', { diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 2ba24e0aa..fe8d8a158 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -399,6 +399,10 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_USB_MICRO_AB = 'usb-micro-ab' TYPE_USB_3_B = 'usb-3-b' TYPE_USB_3_MICROB = 'usb-3-micro-b' + # Molex + TYPE_MOLEX_MICRO_FIT_1X2 = 'molex-micro-fit-1x2' + TYPE_MOLEX_MICRO_FIT_2X2 = 'molex-micro-fit-2x2' + TYPE_MOLEX_MICRO_FIT_2X4 = 'molex-micro-fit-2x4' # Direct current (DC) TYPE_DC = 'dc-terminal' # Proprietary @@ -520,6 +524,11 @@ class PowerPortTypeChoices(ChoiceSet): (TYPE_USB_3_B, 'USB 3.0 Type B'), (TYPE_USB_3_MICROB, 'USB 3.0 Micro B'), )), + ('Molex', ( + (TYPE_MOLEX_MICRO_FIT_1X2, 'Molex Micro-Fit 1x2'), + (TYPE_MOLEX_MICRO_FIT_2X2, 'Molex Micro-Fit 2x2'), + (TYPE_MOLEX_MICRO_FIT_2X4, 'Molex Micro-Fit 2x4'), + )), ('DC', ( (TYPE_DC, 'DC Terminal'), )), @@ -635,6 +644,10 @@ class PowerOutletTypeChoices(ChoiceSet): TYPE_USB_A = 'usb-a' TYPE_USB_MICROB = 'usb-micro-b' TYPE_USB_C = 'usb-c' + # Molex + TYPE_MOLEX_MICRO_FIT_1X2 = 'molex-micro-fit-1x2' + TYPE_MOLEX_MICRO_FIT_2X2 = 'molex-micro-fit-2x2' + TYPE_MOLEX_MICRO_FIT_2X4 = 'molex-micro-fit-2x4' # Direct current (DC) TYPE_DC = 'dc-terminal' # Proprietary @@ -749,6 +762,11 @@ class PowerOutletTypeChoices(ChoiceSet): (TYPE_USB_MICROB, 'USB Micro B'), (TYPE_USB_C, 'USB Type C'), )), + ('Molex', ( + (TYPE_MOLEX_MICRO_FIT_1X2, 'Molex Micro-Fit 1x2'), + (TYPE_MOLEX_MICRO_FIT_2X2, 'Molex Micro-Fit 2x2'), + (TYPE_MOLEX_MICRO_FIT_2X4, 'Molex Micro-Fit 2x4'), + )), ('DC', ( (TYPE_DC, 'DC Terminal'), )), @@ -810,6 +828,7 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_100ME_FIXED = '100base-tx' TYPE_100ME_T1 = '100base-t1' TYPE_1GE_FIXED = '1000base-t' + TYPE_1GE_TX_FIXED = '1000base-tx' TYPE_1GE_GBIC = '1000base-x-gbic' TYPE_1GE_SFP = '1000base-x-sfp' TYPE_2GE_FIXED = '2.5gbase-t' @@ -848,6 +867,8 @@ class InterfaceTypeChoices(ChoiceSet): # Ethernet Backplane TYPE_1GE_KX = '1000base-kx' + TYPE_2GE_KX = '2.5gbase-kx' + TYPE_5GE_KR = '5gbase-kr' TYPE_10GE_KR = '10gbase-kr' TYPE_10GE_KX4 = '10gbase-kx4' TYPE_25GE_KR = '25gbase-kr' @@ -872,6 +893,8 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_GSM = 'gsm' TYPE_CDMA = 'cdma' TYPE_LTE = 'lte' + TYPE_4G = '4g' + TYPE_5G = '5g' # SONET TYPE_SONET_OC3 = 'sonet-oc3' @@ -889,7 +912,10 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_8GFC_SFP_PLUS = '8gfc-sfpp' TYPE_16GFC_SFP_PLUS = '16gfc-sfpp' TYPE_32GFC_SFP28 = '32gfc-sfp28' + TYPE_32GFC_SFP_PLUS = '32gfc-sfpp' TYPE_64GFC_QSFP_PLUS = '64gfc-qsfpp' + TYPE_64GFC_SFP_DD = '64gfc-sfpdd' + TYPE_64GFC_SFP_PLUS = '64gfc-sfpp' TYPE_128GFC_QSFP28 = '128gfc-qsfp28' # InfiniBand @@ -916,12 +942,15 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_DOCSIS = 'docsis' # PON + TYPE_BPON = 'bpon' + TYPE_EPON = 'epon' + TYPE_10G_EPON = '10g-epon' TYPE_GPON = 'gpon' TYPE_XG_PON = 'xg-pon' TYPE_XGS_PON = 'xgs-pon' TYPE_NG_PON2 = 'ng-pon2' - TYPE_EPON = 'epon' - TYPE_10G_EPON = '10g-epon' + TYPE_25G_PON = '25g-pon' + TYPE_50G_PON = '50g-pon' # Stacking TYPE_STACKWISE = 'cisco-stackwise' @@ -959,6 +988,7 @@ class InterfaceTypeChoices(ChoiceSet): (TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'), (TYPE_100ME_T1, '100BASE-T1 (10/100ME Single Pair)'), (TYPE_1GE_FIXED, '1000BASE-T (1GE)'), + (TYPE_1GE_TX_FIXED, '1000BASE-TX (1GE)'), (TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'), (TYPE_5GE_FIXED, '5GBASE-T (5GE)'), (TYPE_10GE_FIXED, '10GBASE-T (10GE)'), @@ -1005,6 +1035,8 @@ class InterfaceTypeChoices(ChoiceSet): _('Ethernet (backplane)'), ( (TYPE_1GE_KX, '1000BASE-KX (1GE)'), + (TYPE_2GE_KX, '2.5GBASE-KX (2.5GE)'), + (TYPE_5GE_KR, '5GBASE-KR (5GE)'), (TYPE_10GE_KR, '10GBASE-KR (10GE)'), (TYPE_10GE_KX4, '10GBASE-KX4 (10GE)'), (TYPE_25GE_KR, '25GBASE-KR (25GE)'), @@ -1035,6 +1067,8 @@ class InterfaceTypeChoices(ChoiceSet): (TYPE_GSM, 'GSM'), (TYPE_CDMA, 'CDMA'), (TYPE_LTE, 'LTE'), + (TYPE_4G, '4G'), + (TYPE_5G, '5G'), ) ), ( @@ -1058,7 +1092,10 @@ class InterfaceTypeChoices(ChoiceSet): (TYPE_8GFC_SFP_PLUS, 'SFP+ (8GFC)'), (TYPE_16GFC_SFP_PLUS, 'SFP+ (16GFC)'), (TYPE_32GFC_SFP28, 'SFP28 (32GFC)'), + (TYPE_32GFC_SFP_PLUS, 'SFP+ (32GFC)'), (TYPE_64GFC_QSFP_PLUS, 'QSFP+ (64GFC)'), + (TYPE_64GFC_SFP_DD, 'SFP-DD (64GFC)'), + (TYPE_64GFC_SFP_PLUS, 'SFP+ (64GFC)'), (TYPE_128GFC_QSFP28, 'QSFP28 (128GFC)'), ) ), @@ -1100,12 +1137,15 @@ class InterfaceTypeChoices(ChoiceSet): ( 'PON', ( - (TYPE_GPON, 'GPON (2.5 Gbps / 1.25 Gps)'), + (TYPE_BPON, 'BPON (622 Mbps / 155 Mbps)'), + (TYPE_EPON, 'EPON (1 Gbps)'), + (TYPE_10G_EPON, '10G-EPON (10 Gbps)'), + (TYPE_GPON, 'GPON (2.5 Gbps / 1.25 Gbps)'), (TYPE_XG_PON, 'XG-PON (10 Gbps / 2.5 Gbps)'), (TYPE_XGS_PON, 'XGS-PON (10 Gbps)'), (TYPE_NG_PON2, 'NG-PON2 (TWDM-PON) (4x10 Gbps)'), - (TYPE_EPON, 'EPON (1 Gbps)'), - (TYPE_10G_EPON, '10G-EPON (10 Gbps)'), + (TYPE_25G_PON, '25G-PON (25 Gbps)'), + (TYPE_50G_PON, '50G-PON (50 Gbps)'), ) ), ( diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index d0163e988..2fb1e9949 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -10,19 +10,20 @@ from extras.filtersets import LocalConfigContextFilterSet from extras.models import ConfigTemplate from ipam.filtersets import PrimaryIPFilterSet from ipam.models import ASN, IPAddress, VRF +from netbox.choices import ColorChoices from netbox.filtersets import ( BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet, ) from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet from tenancy.models import * -from utilities.choices import ColorChoices from utilities.filters import ( ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter, - TreeNodeMultipleChoiceFilter, + NumericArrayFilter, TreeNodeMultipleChoiceFilter, ) from virtualization.models import Cluster from vpn.models import L2VPN from wireless.choices import WirelessRoleChoices, WirelessChannelChoices +from wireless.models import WirelessLAN, WirelessLink from .choices import * from .constants import * from .models import * @@ -89,10 +90,23 @@ class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): to_field_name='slug', label=_('Parent region (slug)'), ) + ancestor_id = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='parent', + lookup_expr='in', + label=_('Region (ID)'), + ) + ancestor = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='parent', + lookup_expr='in', + to_field_name='slug', + label=_('Region (slug)'), + ) class Meta: model = Region - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description') class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): @@ -106,10 +120,23 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): to_field_name='slug', label=_('Parent site group (slug)'), ) + ancestor_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='parent', + lookup_expr='in', + label=_('Site group (ID)'), + ) + ancestor = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='parent', + lookup_expr='in', + to_field_name='slug', + label=_('Site group (slug)'), + ) class Meta: model = SiteGroup - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description') class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): @@ -152,12 +179,11 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe queryset=ASN.objects.all(), label=_('AS (ID)'), ) + time_zone = MultiValueCharFilter() class Meta: model = Site - fields = ( - 'id', 'name', 'slug', 'facility', 'latitude', 'longitude', 'description' - ) + fields = ('id', 'name', 'slug', 'facility', 'latitude', 'longitude', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -214,13 +240,23 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM to_field_name='slug', label=_('Site (slug)'), ) - parent_id = TreeNodeMultipleChoiceFilter( + parent_id = django_filters.ModelMultipleChoiceFilter( + queryset=Location.objects.all(), + label=_('Parent location (ID)'), + ) + parent = django_filters.ModelMultipleChoiceFilter( + field_name='parent__slug', + queryset=Location.objects.all(), + to_field_name='slug', + label=_('Parent location (slug)'), + ) + ancestor_id = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), field_name='parent', lookup_expr='in', label=_('Location (ID)'), ) - parent = TreeNodeMultipleChoiceFilter( + ancestor = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), field_name='parent', lookup_expr='in', @@ -234,13 +270,14 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM class Meta: model = Location - fields = ['id', 'name', 'slug', 'status', 'description'] + fields = ('id', 'name', 'slug', 'status', 'facility', 'description') def search(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter( Q(name__icontains=value) | + Q(facility__icontains=value) | Q(description__icontains=value) ) @@ -249,7 +286,7 @@ class RackRoleFilterSet(OrganizationalModelFilterSet): class Meta: model = RackRole - fields = ['id', 'name', 'slug', 'color', 'description'] + fields = ('id', 'name', 'slug', 'color', 'description') class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): @@ -328,10 +365,10 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe class Meta: model = Rack - fields = [ + fields = ( 'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', - ] + ) def search(self, queryset, name, value): if not value.strip(): @@ -411,10 +448,14 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet): to_field_name='username', label=_('User (name)'), ) + unit = NumericArrayFilter( + field_name='units', + lookup_expr='contains' + ) class Meta: model = RackReservation - fields = ['id', 'created', 'description'] + fields = ('id', 'created', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -431,7 +472,7 @@ class ManufacturerFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet) class Meta: model = Manufacturer - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description') class DeviceTypeFilterSet(NetBoxModelFilterSet): @@ -502,10 +543,22 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): class Meta: model = DeviceType - fields = [ + fields = ( 'id', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'description', - ] + + # Counters + 'console_port_template_count', + 'console_server_port_template_count', + 'power_port_template_count', + 'power_outlet_template_count', + 'interface_template_count', + 'front_port_template_count', + 'rear_port_template_count', + 'device_bay_template_count', + 'module_bay_template_count', + 'inventory_item_template_count', + ) def search(self, queryset, name, value): if not value.strip(): @@ -599,7 +652,7 @@ class ModuleTypeFilterSet(NetBoxModelFilterSet): class Meta: model = ModuleType - fields = ['id', 'model', 'part_number', 'weight', 'weight_unit', 'description'] + fields = ('id', 'model', 'part_number', 'weight', 'weight_unit', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -639,12 +692,15 @@ class DeviceTypeComponentFilterSet(django_filters.FilterSet): method='search', label=_('Search'), ) - devicetype_id = django_filters.ModelMultipleChoiceFilter( + device_type_id = django_filters.ModelMultipleChoiceFilter( queryset=DeviceType.objects.all(), field_name='device_type_id', label=_('Device type (ID)'), ) + # TODO: Remove in v4.1 + devicetype_id = device_type_id + def search(self, queryset, name, value): if not value.strip(): return queryset @@ -655,32 +711,35 @@ class DeviceTypeComponentFilterSet(django_filters.FilterSet): class ModularDeviceTypeComponentFilterSet(DeviceTypeComponentFilterSet): - moduletype_id = django_filters.ModelMultipleChoiceFilter( + module_type_id = django_filters.ModelMultipleChoiceFilter( queryset=ModuleType.objects.all(), field_name='module_type_id', label=_('Module type (ID)'), ) + # TODO: Remove in v4.1 + moduletype_id = module_type_id + class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): class Meta: model = ConsolePortTemplate - fields = ['id', 'name', 'type', 'description'] + fields = ('id', 'name', 'label', 'type', 'description') class ConsoleServerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): class Meta: model = ConsoleServerPortTemplate - fields = ['id', 'name', 'type', 'description'] + fields = ('id', 'name', 'label', 'type', 'description') class PowerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): class Meta: model = PowerPortTemplate - fields = ['id', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description'] + fields = ('id', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): @@ -688,10 +747,14 @@ class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceType choices=PowerOutletFeedLegChoices, null_value=None ) + power_port_id = django_filters.ModelMultipleChoiceFilter( + queryset=PowerPortTemplate.objects.all(), + label=_('Power port (ID)'), + ) class Meta: model = PowerOutletTemplate - fields = ['id', 'name', 'type', 'feed_leg', 'description'] + fields = ('id', 'name', 'label', 'type', 'feed_leg', 'description') class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): @@ -715,7 +778,7 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo class Meta: model = InterfaceTemplate - fields = ['id', 'name', 'type', 'enabled', 'mgmt_only', 'description'] + fields = ('id', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description') class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): @@ -723,10 +786,13 @@ class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo choices=PortTypeChoices, null_value=None ) + rear_port_id = django_filters.ModelMultipleChoiceFilter( + queryset=RearPort.objects.all() + ) class Meta: model = FrontPortTemplate - fields = ['id', 'name', 'type', 'color', 'description'] + fields = ('id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description') class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): @@ -737,21 +803,21 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCom class Meta: model = RearPortTemplate - fields = ['id', 'name', 'type', 'color', 'positions', 'description'] + fields = ('id', 'name', 'label', 'type', 'color', 'positions', 'description') class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): class Meta: model = ModuleBayTemplate - fields = ['id', 'name', 'description'] + fields = ('id', 'name', 'label', 'position', 'description') class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): class Meta: model = DeviceBayTemplate - fields = ['id', 'name', 'description'] + fields = ('id', 'name', 'label', 'description') class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): @@ -784,7 +850,7 @@ class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeCompo class Meta: model = InventoryItemTemplate - fields = ['id', 'name', 'label', 'part_id', 'description'] + fields = ('id', 'name', 'label', 'part_id', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -805,7 +871,7 @@ class DeviceRoleFilterSet(OrganizationalModelFilterSet): class Meta: model = DeviceRole - fields = ['id', 'name', 'slug', 'color', 'vm_role', 'description'] + fields = ('id', 'name', 'slug', 'color', 'vm_role', 'description') class PlatformFilterSet(OrganizationalModelFilterSet): @@ -831,7 +897,7 @@ class PlatformFilterSet(OrganizationalModelFilterSet): class Meta: model = Platform - fields = ['id', 'name', 'slug', 'description'] + fields = ('id', 'name', 'slug', 'description') @extend_schema_field(OpenApiTypes.STR) def get_for_device_type(self, queryset, name, value): @@ -943,6 +1009,11 @@ class DeviceFilterSet( queryset=Rack.objects.all(), label=_('Rack (ID)'), ) + parent_bay_id = django_filters.ModelMultipleChoiceFilter( + field_name='parent_bay', + queryset=DeviceBay.objects.all(), + label=_('Parent bay (ID)'), + ) cluster_id = django_filters.ModelMultipleChoiceFilter( queryset=Cluster.objects.all(), label=_('VM cluster (ID)'), @@ -1029,13 +1100,29 @@ class DeviceFilterSet( queryset=IPAddress.objects.all(), label=_('OOB IP (ID)'), ) + has_virtual_device_context = django_filters.BooleanFilter( + method='_has_virtual_device_context', + label=_('Has virtual device context'), + ) class Meta: model = Device - fields = [ + fields = ( 'id', 'asset_tag', 'face', 'position', 'latitude', 'longitude', 'airflow', 'vc_position', 'vc_priority', 'description', - ] + + # Counters + 'console_port_count', + 'console_server_port_count', + 'power_port_count', + 'power_outlet_count', + 'interface_count', + 'front_port_count', + 'rear_port_count', + 'device_bay_count', + 'module_bay_count', + 'inventory_item_count', + ) def search(self, queryset, name, value): if not value.strip(): @@ -1093,29 +1180,40 @@ class DeviceFilterSet( def _device_bays(self, queryset, name, value): return queryset.exclude(devicebays__isnull=value) + def _has_virtual_device_context(self, queryset, name, value): + params = Q(vdcs__isnull=False) + if value: + return queryset.filter(params).distinct() + return queryset.exclude(params) + class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, PrimaryIPFilterSet): device_id = django_filters.ModelMultipleChoiceFilter( field_name='device', queryset=Device.objects.all(), - label='VDC (ID)', + label=_('VDC (ID)') ) device = django_filters.ModelMultipleChoiceFilter( field_name='device', queryset=Device.objects.all(), - label='Device model', + label=_('Device model') + ) + interface_id = django_filters.ModelMultipleChoiceFilter( + field_name='interfaces', + queryset=Interface.objects.all(), + label=_('Interface (ID)') ) status = django_filters.MultipleChoiceFilter( choices=VirtualDeviceContextStatusChoices ) has_primary_ip = django_filters.BooleanFilter( method='_has_primary_ip', - label='Has a primary IP', + label=_('Has a primary IP') ) class Meta: model = VirtualDeviceContext - fields = ['id', 'device', 'name', 'description'] + fields = ('id', 'device', 'name', 'identifier', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -1181,7 +1279,7 @@ class ModuleFilterSet(NetBoxModelFilterSet): class Meta: model = Module - fields = ['id', 'status', 'asset_tag', 'description'] + fields = ('id', 'status', 'asset_tag', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -1302,18 +1400,6 @@ class DeviceComponentFilterSet(django_filters.FilterSet): to_field_name='name', label=_('Virtual Chassis'), ) - # TODO: Remove in v4.0 - device_role_id = django_filters.ModelMultipleChoiceFilter( - field_name='device__role', - queryset=DeviceRole.objects.all(), - label=_('Device role (ID)'), - ) - device_role = django_filters.ModelMultipleChoiceFilter( - field_name='device__role__slug', - queryset=DeviceRole.objects.all(), - to_field_name='slug', - label=_('Device role (slug)'), - ) def search(self, queryset, name, value): if not value.strip(): @@ -1337,6 +1423,10 @@ class ModularDeviceComponentFilterSet(DeviceComponentFilterSet): class CabledObjectFilterSet(django_filters.FilterSet): + cable_id = django_filters.ModelMultipleChoiceFilter( + queryset=Cable.objects.all(), + label=_('Cable (ID)'), + ) cabled = django_filters.BooleanFilter( field_name='cable', lookup_expr='isnull', @@ -1378,7 +1468,7 @@ class ConsolePortFilterSet( class Meta: model = ConsolePort - fields = ['id', 'name', 'label', 'description', 'cable_end'] + fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end') class ConsoleServerPortFilterSet( @@ -1394,7 +1484,7 @@ class ConsoleServerPortFilterSet( class Meta: model = ConsoleServerPort - fields = ['id', 'name', 'label', 'description', 'cable_end'] + fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end') class PowerPortFilterSet( @@ -1410,7 +1500,9 @@ class PowerPortFilterSet( class Meta: model = PowerPort - fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description', 'cable_end'] + fields = ( + 'id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable_end', + ) class PowerOutletFilterSet( @@ -1427,10 +1519,16 @@ class PowerOutletFilterSet( choices=PowerOutletFeedLegChoices, null_value=None ) + power_port_id = django_filters.ModelMultipleChoiceFilter( + queryset=PowerPort.objects.all(), + label=_('Power port (ID)'), + ) class Meta: model = PowerOutlet - fields = ['id', 'name', 'label', 'feed_leg', 'description', 'cable_end'] + fields = ( + 'id', 'name', 'label', 'feed_leg', 'description', 'mark_connected', 'cable_end', + ) class CommonInterfaceFilterSet(django_filters.FilterSet): @@ -1545,27 +1643,37 @@ class InterfaceFilterSet( vdc_id = django_filters.ModelMultipleChoiceFilter( field_name='vdcs', queryset=VirtualDeviceContext.objects.all(), - label='Virtual Device Context', + label=_('Virtual Device Context') ) vdc_identifier = django_filters.ModelMultipleChoiceFilter( field_name='vdcs__identifier', queryset=VirtualDeviceContext.objects.all(), to_field_name='identifier', - label='Virtual Device Context (Identifier)', + label=_('Virtual Device Context (Identifier)') ) vdc = django_filters.ModelMultipleChoiceFilter( field_name='vdcs__name', queryset=VirtualDeviceContext.objects.all(), to_field_name='name', - label='Virtual Device Context', + label=_('Virtual Device Context') + ) + wireless_lan_id = django_filters.ModelMultipleChoiceFilter( + field_name='wireless_lans', + queryset=WirelessLAN.objects.all(), + label=_('Wireless LAN') + ) + wireless_link_id = django_filters.ModelMultipleChoiceFilter( + queryset=WirelessLink.objects.all(), + label=_('Wireless link') ) class Meta: model = Interface - fields = [ + 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', 'cable_end', - ] + 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', + 'cable_id', 'cable_end', + ) def filter_virtual_chassis_member(self, queryset, name, value): try: @@ -1594,10 +1702,15 @@ class FrontPortFilterSet( choices=PortTypeChoices, null_value=None ) + rear_port_id = django_filters.ModelMultipleChoiceFilter( + queryset=RearPort.objects.all() + ) class Meta: model = FrontPort - fields = ['id', 'name', 'label', 'type', 'color', 'description', 'cable_end'] + fields = ( + 'id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description', 'mark_connected', 'cable_end', + ) class RearPortFilterSet( @@ -1612,21 +1725,38 @@ class RearPortFilterSet( class Meta: model = RearPort - fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description', 'cable_end'] + fields = ( + 'id', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable_end', + ) class ModuleBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet): + installed_module_id = django_filters.ModelMultipleChoiceFilter( + field_name='installed_module', + queryset=ModuleBay.objects.all(), + label=_('Installed module (ID)'), + ) class Meta: model = ModuleBay - fields = ['id', 'name', 'label', 'description'] + fields = ('id', 'name', 'label', 'position', 'description') class DeviceBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet): + installed_device_id = django_filters.ModelMultipleChoiceFilter( + queryset=Device.objects.all(), + label=_('Installed device (ID)'), + ) + installed_device = django_filters.ModelMultipleChoiceFilter( + field_name='installed_device__name', + queryset=Device.objects.all(), + to_field_name='name', + label=_('Installed device (name)'), + ) class Meta: model = DeviceBay - fields = ['id', 'name', 'label', 'description'] + fields = ('id', 'name', 'label', 'description') class InventoryItemFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet): @@ -1662,7 +1792,7 @@ class InventoryItemFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet): class Meta: model = InventoryItem - fields = ['id', 'name', 'label', 'part_id', 'asset_tag', 'discovered'] + fields = ('id', 'name', 'label', 'part_id', 'asset_tag', 'description', 'discovered') def search(self, queryset, name, value): if not value.strip(): @@ -1681,7 +1811,7 @@ class InventoryItemRoleFilterSet(OrganizationalModelFilterSet): class Meta: model = InventoryItemRole - fields = ['id', 'name', 'slug', 'color', 'description'] + fields = ('id', 'name', 'slug', 'color', 'description') class VirtualChassisFilterSet(NetBoxModelFilterSet): @@ -1746,7 +1876,7 @@ class VirtualChassisFilterSet(NetBoxModelFilterSet): class Meta: model = VirtualChassis - fields = ['id', 'domain', 'name', 'description'] + fields = ('id', 'domain', 'name', 'description', 'member_count') def search(self, queryset, name, value): if not value.strip(): @@ -1851,7 +1981,7 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet): class Meta: model = Cable - fields = ['id', 'label', 'length', 'length_unit', 'description'] + fields = ('id', 'label', 'length', 'length_unit', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -1929,12 +2059,12 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet): return self.filter_by_termination_object(queryset, CircuitTermination, value) -class CableTerminationFilterSet(BaseFilterSet): +class CableTerminationFilterSet(ChangeLoggedModelFilterSet): termination_type = ContentTypeFilter() class Meta: model = CableTermination - fields = ['id', 'cable', 'cable_end', 'termination_type', 'termination_id'] + fields = ('id', 'cable', 'cable_end', 'termination_type', 'termination_id') class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): @@ -1983,7 +2113,7 @@ class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): class Meta: model = PowerPanel - fields = ['id', 'name', 'description'] + fields = ('id', 'name', 'description') def search(self, queryset, name, value): if not value.strip(): @@ -2049,10 +2179,10 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpoi class Meta: model = PowerFeed - fields = [ - 'id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'cable_end', - 'description', - ] + fields = ( + 'id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', + 'available_power', 'mark_connected', 'cable_end', 'description', + ) def search(self, queryset, name, value): if not value.strip(): @@ -2111,18 +2241,18 @@ class ConsoleConnectionFilterSet(ConnectionFilterSet): class Meta: model = ConsolePort - fields = ['name'] + fields = ('name',) class PowerConnectionFilterSet(ConnectionFilterSet): class Meta: model = PowerPort - fields = ['name'] + fields = ('name',) class InterfaceConnectionFilterSet(ConnectionFilterSet): class Meta: model = Interface - fields = [] + fields = tuple() diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py index 2a84a9a51..2939b986e 100644 --- a/netbox/dcim/forms/bulk_create.py +++ b/netbox/dcim/forms/bulk_create.py @@ -4,7 +4,7 @@ from django.utils.translation import gettext_lazy as _ from dcim.models import * from extras.models import Tag from netbox.forms.mixins import CustomFieldsMixin -from utilities.forms import BootstrapMixin, form_from_model +from utilities.forms import form_from_model from utilities.forms.fields import DynamicModelMultipleChoiceField, ExpandableNameField from .object_create import ComponentCreateForm @@ -26,7 +26,7 @@ __all__ = ( # Device components # -class DeviceBulkAddComponentForm(BootstrapMixin, CustomFieldsMixin, ComponentCreateForm): +class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentCreateForm): pk = forms.ModelMultipleChoiceField( queryset=Device.objects.all(), widget=forms.MultipleHiddenInput() diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 68d8d4f89..25b049e6d 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -13,6 +13,7 @@ from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import BulkEditForm, add_blank_choice, form_from_model from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField +from utilities.forms.rendering import FieldSet from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions from wireless.models import WirelessLAN, WirelessLANGroup from wireless.choices import WirelessRoleChoices @@ -75,7 +76,7 @@ class RegionBulkEditForm(NetBoxModelBulkEditForm): model = Region fieldsets = ( - (None, ('parent', 'description')), + FieldSet('parent', 'description'), ) nullable_fields = ('parent', 'description') @@ -94,7 +95,7 @@ class SiteGroupBulkEditForm(NetBoxModelBulkEditForm): model = SiteGroup fieldsets = ( - (None, ('parent', 'description')), + FieldSet('parent', 'description'), ) nullable_fields = ('parent', 'description') @@ -154,7 +155,7 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm): model = Site fieldsets = ( - (None, ('status', 'region', 'group', 'tenant', 'asns', 'time_zone', 'description')), + FieldSet('status', 'region', 'group', 'tenant', 'asns', 'time_zone', 'description'), ) nullable_fields = ( 'region', 'group', 'tenant', 'asns', 'time_zone', 'description', 'comments', @@ -194,7 +195,7 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm): model = Location fieldsets = ( - (None, ('site', 'parent', 'status', 'tenant', 'description')), + FieldSet('site', 'parent', 'status', 'tenant', 'description'), ) nullable_fields = ('parent', 'tenant', 'description') @@ -212,7 +213,7 @@ class RackRoleBulkEditForm(NetBoxModelBulkEditForm): model = RackRole fieldsets = ( - (None, ('color', 'description')), + FieldSet('color', 'description'), ) nullable_fields = ('color', 'description') @@ -341,12 +342,13 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): model = Rack fieldsets = ( - (_('Rack'), ('status', 'role', 'tenant', 'serial', 'asset_tag', 'description')), - (_('Location'), ('region', 'site_group', 'site', 'location')), - (_('Hardware'), ( + FieldSet('status', 'role', 'tenant', 'serial', 'asset_tag', 'description', name=_('Rack')), + FieldSet('region', 'site_group', 'site', 'location', name=_('Location')), + FieldSet( 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', - )), - (_('Weight'), ('weight', 'max_weight', 'weight_unit')), + name=_('Hardware') + ), + FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')), ) nullable_fields = ( 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'weight', @@ -376,7 +378,7 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm): model = RackReservation fieldsets = ( - (None, ('user', 'tenant', 'description')), + FieldSet('user', 'tenant', 'description'), ) nullable_fields = ('comments',) @@ -390,7 +392,7 @@ class ManufacturerBulkEditForm(NetBoxModelBulkEditForm): model = Manufacturer fieldsets = ( - (None, ('description',)), + FieldSet('description'), ) nullable_fields = ('description',) @@ -450,11 +452,11 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): model = DeviceType fieldsets = ( - (_('Device Type'), ( + FieldSet( 'manufacturer', 'default_platform', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth', - 'airflow', 'description', - )), - (_('Weight'), ('weight', 'weight_unit')), + 'airflow', 'description', name=_('Device Type') + ), + FieldSet('weight', 'weight_unit', name=_('Weight')), ) nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments') @@ -489,8 +491,8 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): model = ModuleType fieldsets = ( - (_('Module Type'), ('manufacturer', 'part_number', 'description')), - (_('Weight'), ('weight', 'weight_unit')), + FieldSet('manufacturer', 'part_number', 'description', name=_('Module Type')), + FieldSet('weight', 'weight_unit', name=_('Weight')), ) nullable_fields = ('part_number', 'weight', 'weight_unit', 'description', 'comments') @@ -518,7 +520,7 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): model = DeviceRole fieldsets = ( - (None, ('color', 'vm_role', 'config_template', 'description')), + FieldSet('color', 'vm_role', 'config_template', 'description'), ) nullable_fields = ('color', 'config_template', 'description') @@ -542,7 +544,7 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm): model = Platform fieldsets = ( - (None, ('manufacturer', 'config_template', 'description')), + FieldSet('manufacturer', 'config_template', 'description'), ) nullable_fields = ('manufacturer', 'config_template', 'description') @@ -557,6 +559,9 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm): label=_('Device type'), queryset=DeviceType.objects.all(), required=False, + context={ + 'parent': 'manufacturer', + }, query_params={ 'manufacturer_id': '$manufacturer' } @@ -618,10 +623,10 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm): model = Device fieldsets = ( - (_('Device'), ('role', 'status', 'tenant', 'platform', 'description')), - (_('Location'), ('site', 'location')), - (_('Hardware'), ('manufacturer', 'device_type', 'airflow', 'serial')), - (_('Configuration'), ('config_template',)), + FieldSet('role', 'status', 'tenant', 'platform', 'description', name=_('Device')), + FieldSet('site', 'location', name=_('Location')), + FieldSet('manufacturer', 'device_type', 'airflow', 'serial', name=_('Hardware')), + FieldSet('config_template', name=_('Configuration')), ) nullable_fields = ( 'location', 'tenant', 'platform', 'serial', 'airflow', 'description', 'comments', @@ -640,6 +645,9 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm): required=False, query_params={ 'manufacturer_id': '$manufacturer' + }, + context={ + 'parent': 'manufacturer', } ) status = forms.ChoiceField( @@ -662,7 +670,7 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm): model = Module fieldsets = ( - (None, ('manufacturer', 'module_type', 'status', 'serial', 'description')), + FieldSet('manufacturer', 'module_type', 'status', 'serial', 'description'), ) nullable_fields = ('serial', 'description', 'comments') @@ -714,8 +722,8 @@ class CableBulkEditForm(NetBoxModelBulkEditForm): model = Cable fieldsets = ( - (None, ('type', 'status', 'tenant', 'label', 'description')), - (_('Attributes'), ('color', 'length', 'length_unit')), + FieldSet('type', 'status', 'tenant', 'label', 'description'), + FieldSet('color', 'length', 'length_unit', name=_('Attributes')), ) nullable_fields = ( 'type', 'status', 'tenant', 'label', 'color', 'length', 'description', 'comments', @@ -737,7 +745,7 @@ class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm): model = VirtualChassis fieldsets = ( - (None, ('domain', 'description')), + FieldSet('domain', 'description'), ) nullable_fields = ('domain', 'description', 'comments') @@ -785,7 +793,7 @@ class PowerPanelBulkEditForm(NetBoxModelBulkEditForm): model = PowerPanel fieldsets = ( - (None, ('region', 'site_group', 'site', 'location', 'description')), + FieldSet('region', 'site_group', 'site', 'location', 'description'), ) nullable_fields = ('location', 'description', 'comments') @@ -855,8 +863,8 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): model = PowerFeed fieldsets = ( - (None, ('power_panel', 'rack', 'status', 'type', 'mark_connected', 'description', 'tenant')), - (_('Power'), ('supply', 'phase', 'voltage', 'amperage', 'max_utilization')) + FieldSet('power_panel', 'rack', 'status', 'type', 'mark_connected', 'description', 'tenant'), + FieldSet('supply', 'phase', 'voltage', 'amperage', 'max_utilization', name=_('Power')) ) nullable_fields = ('location', 'tenant', 'description', 'comments') @@ -1204,7 +1212,7 @@ class ConsolePortBulkEditForm( model = ConsolePort fieldsets = ( - (None, ('module', 'type', 'label', 'speed', 'description', 'mark_connected')), + FieldSet('module', 'type', 'label', 'speed', 'description', 'mark_connected'), ) nullable_fields = ('module', 'label', 'description') @@ -1221,7 +1229,7 @@ class ConsoleServerPortBulkEditForm( model = ConsoleServerPort fieldsets = ( - (None, ('module', 'type', 'label', 'speed', 'description', 'mark_connected')), + FieldSet('module', 'type', 'label', 'speed', 'description', 'mark_connected'), ) nullable_fields = ('module', 'label', 'description') @@ -1238,8 +1246,8 @@ class PowerPortBulkEditForm( model = PowerPort fieldsets = ( - (None, ('module', 'type', 'label', 'description', 'mark_connected')), - (_('Power'), ('maximum_draw', 'allocated_draw')), + FieldSet('module', 'type', 'label', 'description', 'mark_connected'), + FieldSet('maximum_draw', 'allocated_draw', name=_('Power')), ) nullable_fields = ('module', 'label', 'description', 'maximum_draw', 'allocated_draw') @@ -1256,8 +1264,8 @@ class PowerOutletBulkEditForm( model = PowerOutlet fieldsets = ( - (None, ('module', 'type', 'label', 'description', 'mark_connected')), - (_('Power'), ('feed_leg', 'power_port')), + FieldSet('module', 'type', 'label', 'description', 'mark_connected'), + FieldSet('feed_leg', 'power_port', name=_('Power')), ) nullable_fields = ('module', 'label', 'type', 'feed_leg', 'power_port', 'description') @@ -1389,20 +1397,21 @@ class InterfaceBulkEditForm( model = Interface fieldsets = ( - (None, ('module', 'type', 'label', 'speed', 'duplex', 'description')), - (_('Addressing'), ('vrf', 'mac_address', 'wwn')), - (_('Operation'), ('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), - (_('PoE'), ('poe_mode', 'poe_type')), - (_('Related Interfaces'), ('parent', 'bridge', 'lag')), - (_('802.1Q Switching'), ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), - (_('Wireless'), ( + FieldSet('module', 'type', 'label', 'speed', 'duplex', 'description'), + FieldSet('vrf', 'mac_address', 'wwn', name=_('Addressing')), + FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')), + FieldSet('poe_mode', 'poe_type', name=_('PoE')), + FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')), + FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', name=_('802.1Q Switching')), + FieldSet( 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans', - )), + name=_('Wireless') + ), ) nullable_fields = ( - 'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'vdcs', 'mtu', 'description', - 'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', - 'tagged_vlans', 'vrf', 'wireless_lans' + 'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'vdcs', 'mtu', + 'description', 'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', + 'tx_power', 'untagged_vlan', 'tagged_vlans', 'vrf', 'wireless_lans' ) def __init__(self, *args, **kwargs): @@ -1411,9 +1420,9 @@ class InterfaceBulkEditForm( device = Device.objects.filter(pk=self.initial['device']).first() # Restrict parent/bridge/LAG interface assignment by device - self.fields['parent'].widget.add_query_param('device_id', device.pk) - self.fields['bridge'].widget.add_query_param('device_id', device.pk) - self.fields['lag'].widget.add_query_param('device_id', device.pk) + self.fields['parent'].widget.add_query_param('virtual_chassis_member_id', device.pk) + self.fields['bridge'].widget.add_query_param('virtual_chassis_member_id', device.pk) + self.fields['lag'].widget.add_query_param('virtual_chassis_member_id', device.pk) # Limit VLAN choices by device self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk) @@ -1482,7 +1491,7 @@ class FrontPortBulkEditForm( model = FrontPort fieldsets = ( - (None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')), + FieldSet('module', 'type', 'label', 'color', 'description', 'mark_connected'), ) nullable_fields = ('module', 'label', 'description', 'color') @@ -1499,7 +1508,7 @@ class RearPortBulkEditForm( model = RearPort fieldsets = ( - (None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')), + FieldSet('module', 'type', 'label', 'color', 'description', 'mark_connected'), ) nullable_fields = ('module', 'label', 'description', 'color') @@ -1510,7 +1519,7 @@ class ModuleBayBulkEditForm( ): model = ModuleBay fieldsets = ( - (None, ('label', 'position', 'description')), + FieldSet('label', 'position', 'description'), ) nullable_fields = ('label', 'position', 'description') @@ -1521,7 +1530,7 @@ class DeviceBayBulkEditForm( ): model = DeviceBay fieldsets = ( - (None, ('label', 'description')), + FieldSet('label', 'description'), ) nullable_fields = ('label', 'description') @@ -1548,7 +1557,7 @@ class InventoryItemBulkEditForm( model = InventoryItem fieldsets = ( - (None, ('device', 'label', 'role', 'manufacturer', 'part_id', 'description')), + FieldSet('device', 'label', 'role', 'manufacturer', 'part_id', 'description'), ) nullable_fields = ('label', 'role', 'manufacturer', 'part_id', 'description') @@ -1570,7 +1579,7 @@ class InventoryItemRoleBulkEditForm(NetBoxModelBulkEditForm): model = InventoryItemRole fieldsets = ( - (None, ('color', 'description')), + FieldSet('color', 'description'), ) nullable_fields = ('color', 'description') @@ -1593,6 +1602,6 @@ class VirtualDeviceContextBulkEditForm(NetBoxModelBulkEditForm): ) model = VirtualDeviceContext fieldsets = ( - (None, ('device', 'status', 'tenant')), + FieldSet('device', 'status', 'tenant'), ) nullable_fields = ('device', 'tenant', ) diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 47974096f..5a64cad02 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -157,7 +157,7 @@ class LocationImportForm(NetBoxModelImportForm): class Meta: model = Location - fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description', 'tags') + fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'facility', 'description', 'tags') def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) @@ -1373,14 +1373,14 @@ class VirtualDeviceContextImportForm(NetBoxModelImportForm): label=_('Device'), queryset=Device.objects.all(), to_field_name='name', - help_text='Assigned role' + help_text=_('Assigned role') ) tenant = CSVModelChoiceField( label=_('Tenant'), queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Assigned tenant' + help_text=_('Assigned tenant') ) status = CSVChoiceField( label=_('Status'), diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py index 854c5ebed..44bea047a 100644 --- a/netbox/dcim/forms/connections.py +++ b/netbox/dcim/forms/connections.py @@ -1,4 +1,5 @@ from django import forms +from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ from circuits.models import Circuit, CircuitTermination @@ -30,7 +31,9 @@ def get_cable_form(a_type, b_type): attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField( queryset=term_cls.objects.all(), label=term_cls._meta.verbose_name.title(), - disabled_indicator='_occupied', + context={ + 'disabled': '_occupied', + }, query_params={ 'device_id': f'$termination_{cable_end}_device', 'kind': 'physical', # Exclude virtual interfaces @@ -52,7 +55,9 @@ def get_cable_form(a_type, b_type): attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField( queryset=term_cls.objects.all(), label=_('Power Feed'), - disabled_indicator='_occupied', + context={ + 'disabled': '_occupied', + }, query_params={ 'power_panel_id': f'$termination_{cable_end}_powerpanel', } @@ -72,7 +77,9 @@ def get_cable_form(a_type, b_type): attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField( queryset=term_cls.objects.all(), label=_('Side'), - disabled_indicator='_occupied', + context={ + 'disabled': '_occupied', + }, query_params={ 'circuit_id': f'$termination_{cable_end}_circuit', } @@ -82,25 +89,42 @@ def get_cable_form(a_type, b_type): class _CableForm(CableForm, metaclass=FormMetaclass): - def __init__(self, *args, **kwargs): + def __init__(self, *args, initial=None, **kwargs): + initial = initial or {} + + if a_type: + a_ct = ContentType.objects.get_for_model(a_type) + initial['a_terminations_type'] = f'{a_ct.app_label}.{a_ct.model}' + if b_type: + b_ct = ContentType.objects.get_for_model(b_type) + initial['b_terminations_type'] = f'{b_ct.app_label}.{b_ct.model}' # TODO: Temporary hack to work around list handling limitations with utils.normalize_querydict() for field_name in ('a_terminations', 'b_terminations'): - if field_name in kwargs.get('initial', {}) and type(kwargs['initial'][field_name]) is not list: - kwargs['initial'][field_name] = [kwargs['initial'][field_name]] + if field_name in initial and type(initial[field_name]) is not list: + initial[field_name] = [initial[field_name]] - super().__init__(*args, **kwargs) + super().__init__(*args, initial=initial, **kwargs) if self.instance and self.instance.pk: # Initialize A/B terminations when modifying an existing Cable instance - self.initial['a_terminations'] = self.instance.a_terminations - self.initial['b_terminations'] = self.instance.b_terminations + if a_type and self.instance.a_terminations and a_ct == ContentType.objects.get_for_model(self.instance.a_terminations[0]): + self.initial['a_terminations'] = self.instance.a_terminations + if b_type and self.instance.b_terminations and b_ct == ContentType.objects.get_for_model(self.instance.b_terminations[0]): + self.initial['b_terminations'] = self.instance.b_terminations + else: + # Need to clear terminations if swapped type - but need to do it only + # if not from instance + if a_type: + initial.pop('a_terminations', None) + if b_type: + initial.pop('b_terminations', None) def clean(self): super().clean() # Set the A/B terminations on the Cable instance - self.instance.a_terminations = self.cleaned_data['a_terminations'] - self.instance.b_terminations = self.cleaned_data['b_terminations'] + self.instance.a_terminations = self.cleaned_data.get('a_terminations', []) + self.instance.b_terminations = self.cleaned_data.get('b_terminations', []) return _CableForm diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 95c441381..0a28a4ec4 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -12,7 +12,8 @@ from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import ContactModelFilterForm, TenancyFilterForm from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField -from utilities.forms.widgets import APISelectMultiple, NumberWithOptions +from utilities.forms.rendering import FieldSet +from utilities.forms.widgets import NumberWithOptions from vpn.models import L2VPN from wireless.choices import * @@ -132,8 +133,8 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm): class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Region fieldsets = ( - (None, ('q', 'filter_id', 'tag', 'parent_id')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')) + FieldSet('q', 'filter_id', 'tag', 'parent_id'), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')) ) parent_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -146,8 +147,8 @@ class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = SiteGroup fieldsets = ( - (None, ('q', 'filter_id', 'tag', 'parent_id')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')) + FieldSet('q', 'filter_id', 'tag', 'parent_id'), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')) ) parent_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), @@ -160,10 +161,10 @@ class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Site fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('status', 'region_id', 'group_id', 'asn_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('status', 'region_id', 'group_id', 'asn_id', name=_('Attributes')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), ) selector_fields = ('filter_id', 'q', 'region_id', 'group_id') status = forms.MultipleChoiceField( @@ -192,10 +193,10 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Location fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('region_id', 'site_group_id', 'site_id', 'parent_id', 'status', name=_('Attributes')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -241,13 +242,13 @@ class RackRoleFilterForm(NetBoxModelFilterSetForm): class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Rack fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id')), - (_('Function'), ('status', 'role_id')), - (_('Hardware'), ('type', 'width', 'serial', 'asset_tag')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')), - (_('Weight'), ('weight', 'max_weight', 'weight_unit')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')), + FieldSet('status', 'role_id', name=_('Function')), + FieldSet('type', 'width', 'serial', 'asset_tag', name=_('Hardware')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), + FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')), ) selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id') region_id = DynamicModelMultipleChoiceField( @@ -326,13 +327,13 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte class RackElevationFilterForm(RackFilterForm): fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'id')), - (_('Function'), ('status', 'role_id')), - (_('Hardware'), ('type', 'width', 'serial', 'asset_tag')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')), - (_('Weight'), ('weight', 'max_weight', 'weight_unit')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'id', name=_('Location')), + FieldSet('status', 'role_id', name=_('Function')), + FieldSet('type', 'width', 'serial', 'asset_tag', name=_('Hardware')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), + FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')), ) id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), @@ -348,10 +349,10 @@ class RackElevationFilterForm(RackFilterForm): class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = RackReservation fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('User'), ('user_id',)), - (_('Rack'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('user_id', name=_('User')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Rack')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -393,10 +394,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): user_id = DynamicModelMultipleChoiceField( queryset=get_user_model().objects.all(), required=False, - label=_('User'), - widget=APISelectMultiple( - api_url='/api/users/users/', - ) + label=_('User') ) tag = TagFilterField(model) @@ -404,8 +402,8 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Manufacturer fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')) + FieldSet('q', 'filter_id', 'tag'), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')) ) tag = TagFilterField(model) @@ -413,14 +411,16 @@ class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class DeviceTypeFilterForm(NetBoxModelFilterSetForm): model = DeviceType fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Hardware'), ('manufacturer_id', 'default_platform_id', 'part_number', 'subdevice_role', 'airflow')), - (_('Images'), ('has_front_image', 'has_rear_image')), - (_('Components'), ( + FieldSet('q', 'filter_id', 'tag'), + FieldSet( + 'manufacturer_id', 'default_platform_id', 'part_number', 'subdevice_role', 'airflow', name=_('Hardware') + ), + FieldSet('has_front_image', 'has_rear_image', name=_('Images')), + FieldSet( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', - 'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', - )), - (_('Weight'), ('weight', 'weight_unit')), + 'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', name=_('Components') + ), + FieldSet('weight', 'weight_unit', name=_('Weight')), ) selector_fields = ('filter_id', 'q', 'manufacturer_id') manufacturer_id = DynamicModelMultipleChoiceField( @@ -539,20 +539,19 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): class ModuleTypeFilterForm(NetBoxModelFilterSetForm): model = ModuleType fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Hardware'), ('manufacturer_id', 'part_number')), - (_('Components'), ( + FieldSet('q', 'filter_id', 'tag'), + FieldSet('manufacturer_id', 'part_number', name=_('Hardware')), + FieldSet( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', - 'pass_through_ports', - )), - (_('Weight'), ('weight', 'weight_unit')), + 'pass_through_ports', name=_('Components') + ), + FieldSet('weight', 'weight_unit', name=_('Weight')), ) selector_fields = ('filter_id', 'q', 'manufacturer_id') manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), required=False, - label=_('Manufacturer'), - fetch_trigger='open' + label=_('Manufacturer') ) part_number = forms.CharField( label=_('Part number'), @@ -646,18 +645,21 @@ class DeviceFilterForm( ): model = Device fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Operation'), ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')), - (_('Hardware'), ('manufacturer_id', 'device_type_id', 'platform_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')), - (_('Components'), ( + FieldSet('q', 'filter_id', 'tag'), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address', name=_('Operation')), + FieldSet('manufacturer_id', 'device_type_id', 'platform_id', name=_('Hardware')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), + FieldSet( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', - )), - (_('Miscellaneous'), ( + name=_('Components') + ), + FieldSet( 'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data', - )) + 'has_virtual_device_context', + name=_('Miscellaneous') + ) ) selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id') region_id = DynamicModelMultipleChoiceField( @@ -758,7 +760,7 @@ class DeviceFilterForm( ) has_oob_ip = forms.NullBooleanField( required=False, - label='Has an OOB IP', + label=_('Has an OOB IP'), widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) @@ -812,6 +814,13 @@ class DeviceFilterForm( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + has_virtual_device_context = forms.NullBooleanField( + required=False, + label=_('Has virtual device contexts'), + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) tag = TagFilterField(model) @@ -821,15 +830,14 @@ class VirtualDeviceContextFilterForm( ): model = VirtualDeviceContext fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('device', 'status', 'has_primary_ip')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('device', 'status', 'has_primary_ip', name=_('Attributes')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) device = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, - label=_('Device'), - fetch_trigger='open' + label=_('Device') ) status = forms.MultipleChoiceField( label=_('Status'), @@ -849,14 +857,13 @@ class VirtualDeviceContextFilterForm( class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm): model = Module fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Hardware'), ('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag', name=_('Hardware')), ) manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), required=False, - label=_('Manufacturer'), - fetch_trigger='open' + label=_('Manufacturer') ) module_type_id = DynamicModelMultipleChoiceField( queryset=ModuleType.objects.all(), @@ -864,8 +871,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo query_params={ 'manufacturer_id': '$manufacturer_id' }, - label=_('Type'), - fetch_trigger='open' + label=_('Type') ) status = forms.MultipleChoiceField( label=_('Status'), @@ -886,9 +892,9 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VirtualChassis fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Location'), ('region_id', 'site_group_id', 'site_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -915,10 +921,10 @@ class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Cable fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Location'), ('site_id', 'location_id', 'rack_id', 'device_id')), - (_('Attributes'), ('type', 'status', 'color', 'length', 'length_unit', 'unterminated')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')), + FieldSet('type', 'status', 'color', 'length', 'length_unit', 'unterminated', name=_('Attributes')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -977,9 +983,9 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): label=_('Color'), required=False ) - length = forms.IntegerField( + length = forms.DecimalField( label=_('Length'), - required=False + required=False, ) length_unit = forms.ChoiceField( label=_('Length unit'), @@ -999,9 +1005,9 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = PowerPanel fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id')), - (_('Contacts'), ('contact', 'contact_role', 'contact_group')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), ) selector_fields = ('filter_id', 'q', 'site_id', 'location_id') region_id = DynamicModelMultipleChoiceField( @@ -1038,10 +1044,10 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): class PowerFeedFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = PowerFeed fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), - (_('Attributes'), ('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id', name=_('Location')), + FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), + FieldSet('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', name=_('Attributes')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -1148,11 +1154,11 @@ class PathEndpointFilterForm(CabledFilterForm): class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = ConsolePort fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label', 'type', 'speed')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), - (_('Connection'), ('cabled', 'connected', 'occupied')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')), + FieldSet('cabled', 'connected', 'occupied', name=_('Connection')), ) type = forms.MultipleChoiceField( label=_('Type'), @@ -1170,11 +1176,11 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = ConsoleServerPort fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label', 'type', 'speed')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), - (_('Connection'), ('cabled', 'connected', 'occupied')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')), + FieldSet('cabled', 'connected', 'occupied', name=_('Connection')), ) type = forms.MultipleChoiceField( label=_('Type'), @@ -1192,11 +1198,11 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = PowerPort fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label', 'type')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), - (_('Connection'), ('cabled', 'connected', 'occupied')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'label', 'type', name=_('Attributes')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')), + FieldSet('cabled', 'connected', 'occupied', name=_('Connection')), ) type = forms.MultipleChoiceField( label=_('Type'), @@ -1209,11 +1215,11 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = PowerOutlet fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label', 'type')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), - (_('Connection'), ('cabled', 'connected', 'occupied')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'label', 'type', name=_('Attributes')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')), + FieldSet('cabled', 'connected', 'occupied', name=_('Connection')), ) type = forms.MultipleChoiceField( label=_('Type'), @@ -1226,14 +1232,14 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = Interface fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')), - (_('Addressing'), ('vrf_id', 'l2vpn_id', 'mac_address', 'wwn')), - (_('PoE'), ('poe_mode', 'poe_type')), - (_('Wireless'), ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')), - (_('Connection'), ('cabled', 'connected', 'occupied')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only', name=_('Attributes')), + FieldSet('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')), + FieldSet('poe_mode', 'poe_type', name=_('PoE')), + FieldSet('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', name=_('Wireless')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id', name=_('Device')), + FieldSet('cabled', 'connected', 'occupied', name=_('Connection')), ) selector_fields = ('filter_id', 'q', 'device_id') vdc_id = DynamicModelMultipleChoiceField( @@ -1337,11 +1343,11 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label', 'type', 'color')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), - (_('Cable'), ('cabled', 'occupied')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'label', 'type', 'color', name=_('Attributes')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')), + FieldSet('cabled', 'occupied', name=_('Cable')), ) model = FrontPort type = forms.MultipleChoiceField( @@ -1359,11 +1365,11 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): model = RearPort fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label', 'type', 'color')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), - (_('Cable'), ('cabled', 'occupied')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'label', 'type', 'color', name=_('Attributes')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')), + FieldSet('cabled', 'occupied', name=_('Cable')), ) type = forms.MultipleChoiceField( label=_('Type'), @@ -1380,10 +1386,10 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): class ModuleBayFilterForm(DeviceComponentFilterForm): model = ModuleBay fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label', 'position')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'label', 'position', name=_('Attributes')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')), ) tag = TagFilterField(model) position = forms.CharField( @@ -1395,10 +1401,10 @@ class ModuleBayFilterForm(DeviceComponentFilterForm): class DeviceBayFilterForm(DeviceComponentFilterForm): model = DeviceBay fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet('name', 'label', name=_('Attributes')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')), ) tag = TagFilterField(model) @@ -1406,16 +1412,18 @@ class DeviceBayFilterForm(DeviceComponentFilterForm): class InventoryItemFilterForm(DeviceComponentFilterForm): model = InventoryItem fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')), - (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), - (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), + FieldSet('q', 'filter_id', 'tag'), + FieldSet( + 'name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered', + name=_('Attributes') + ), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), + FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')), ) role_id = DynamicModelMultipleChoiceField( queryset=InventoryItemRole.objects.all(), required=False, - label=_('Role'), - fetch_trigger='open' + label=_('Role') ) manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 6773bc55f..d5cc0e856 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -11,11 +11,11 @@ from extras.models import ConfigTemplate from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm -from utilities.forms import BootstrapMixin, add_blank_choice +from utilities.forms import add_blank_choice from utilities.forms.fields import ( - CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, - NumericArrayField, SlugField, + CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SlugField, ) +from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK from virtualization.models import Cluster from wireless.models import WirelessLAN, WirelessLANGroup @@ -77,9 +77,7 @@ class RegionForm(NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Region'), ( - 'parent', 'name', 'slug', 'description', 'tags', - )), + FieldSet('parent', 'name', 'slug', 'description', 'tags'), ) class Meta: @@ -98,9 +96,7 @@ class SiteGroupForm(NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Site Group'), ( - 'parent', 'name', 'slug', 'description', 'tags', - )), + FieldSet('parent', 'name', 'slug', 'description', 'tags'), ) class Meta: @@ -135,11 +131,12 @@ class SiteForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Site'), ( + FieldSet( 'name', 'slug', 'status', 'region', 'group', 'facility', 'asns', 'time_zone', 'description', 'tags', - )), - (_('Tenancy'), ('tenant_group', 'tenant')), - (_('Contact Info'), ('physical_address', 'shipping_address', 'latitude', 'longitude')), + name=_('Site') + ), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), + FieldSet('physical_address', 'shipping_address', 'latitude', 'longitude', name=_('Contact Info')), ) class Meta: @@ -179,14 +176,14 @@ class LocationForm(TenancyForm, NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Location'), ('site', 'parent', 'name', 'slug', 'status', 'description', 'tags')), - (_('Tenancy'), ('tenant_group', 'tenant')), + FieldSet('site', 'parent', 'name', 'slug', 'status', 'facility', 'description', 'tags', name=_('Location')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) class Meta: model = Location fields = ( - 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant', 'tags', + 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant', 'facility', 'tags', ) @@ -194,9 +191,7 @@ class RackRoleForm(NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Rack Role'), ( - 'name', 'slug', 'color', 'description', 'tags', - )), + FieldSet('name', 'slug', 'color', 'description', 'tags', name=_('Rack Role')), ) class Meta: @@ -227,6 +222,18 @@ class RackForm(TenancyForm, NetBoxModelForm): ) comments = CommentField() + fieldsets = ( + FieldSet('site', 'location', 'name', 'status', 'role', 'description', 'tags', name=_('Rack')), + FieldSet('facility_id', 'serial', 'asset_tag', name=_('Inventory Control')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), + FieldSet( + 'type', 'width', 'starting_unit', 'u_height', + InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')), + InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')), + 'mounting_depth', 'desc_units', name=_('Dimensions') + ), + ) + class Meta: model = Rack fields = [ @@ -256,8 +263,8 @@ class RackReservationForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Reservation'), ('rack', 'units', 'user', 'description', 'tags')), - (_('Tenancy'), ('tenant_group', 'tenant')), + FieldSet('rack', 'units', 'user', 'description', 'tags', name=_('Reservation')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) class Meta: @@ -271,9 +278,7 @@ class ManufacturerForm(NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Manufacturer'), ( - 'name', 'slug', 'description', 'tags', - )), + FieldSet('name', 'slug', 'description', 'tags', name=_('Manufacturer')), ) class Meta: @@ -304,12 +309,12 @@ class DeviceTypeForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Device Type'), ('manufacturer', 'model', 'slug', 'default_platform', 'description', 'tags')), - (_('Chassis'), ( + FieldSet('manufacturer', 'model', 'slug', 'default_platform', 'description', 'tags', name=_('Device Type')), + FieldSet( 'u_height', 'exclude_from_utilization', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow', - 'weight', 'weight_unit', - )), - (_('Images'), ('front_image', 'rear_image')), + 'weight', 'weight_unit', name=_('Chassis') + ), + FieldSet('front_image', 'rear_image', name=_('Images')), ) class Meta: @@ -337,8 +342,8 @@ class ModuleTypeForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Module Type'), ('manufacturer', 'model', 'part_number', 'description', 'tags')), - (_('Weight'), ('weight', 'weight_unit')) + FieldSet('manufacturer', 'model', 'part_number', 'description', 'tags', name=_('Module Type')), + FieldSet('weight', 'weight_unit', name=_('Weight')) ) class Meta: @@ -357,9 +362,9 @@ class DeviceRoleForm(NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Device Role'), ( - 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags', - )), + FieldSet( + 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags', name=_('Device Role') + ), ) class Meta: @@ -386,7 +391,7 @@ class PlatformForm(NetBoxModelForm): ) fieldsets = ( - (_('Platform'), ('name', 'slug', 'manufacturer', 'config_template', 'description', 'tags')), + FieldSet('name', 'slug', 'manufacturer', 'config_template', 'description', 'tags', name=_('Platform')), ) class Meta: @@ -430,7 +435,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm): widget=APISelect( api_url='/api/dcim/racks/{{rack}}/elevation/', attrs={ - 'disabled-indicator': 'device', + 'ts-disabled-field': 'device', 'data-dynamic-params': '[{"fieldName":"face","queryParam":"face"}]' }, ) @@ -438,6 +443,9 @@ class DeviceForm(TenancyForm, NetBoxModelForm): device_type = DynamicModelChoiceField( label=_('Device type'), queryset=DeviceType.objects.all(), + context={ + 'parent': 'manufacturer', + }, selector=True ) role = DynamicModelChoiceField( @@ -468,6 +476,9 @@ class DeviceForm(TenancyForm, NetBoxModelForm): label=_('Virtual chassis'), queryset=VirtualChassis.objects.all(), required=False, + context={ + 'parent': 'master', + }, selector=True ) vc_position = forms.IntegerField( @@ -575,6 +586,9 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm): module_type = DynamicModelChoiceField( label=_('Module type'), queryset=ModuleType.objects.all(), + context={ + 'parent': 'manufacturer', + }, selector=True ) comments = CommentField() @@ -592,10 +606,8 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm): ) fieldsets = ( - (_('Module'), ('device', 'module_bay', 'module_type', 'status', 'description', 'tags')), - (_('Hardware'), ( - 'serial', 'asset_tag', 'replicate_components', 'adopt_components', - )), + FieldSet('device', 'module_bay', 'module_type', 'status', 'description', 'tags', name=_('Module')), + FieldSet('serial', 'asset_tag', 'replicate_components', 'adopt_components', name=_('Hardware')), ) class Meta: @@ -616,14 +628,33 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm): self.fields['adopt_components'].disabled = True +def get_termination_type_choices(): + return add_blank_choice([ + (f'{ct.app_label}.{ct.model}', ct.model_class()._meta.verbose_name.title()) + for ct in ContentType.objects.filter(CABLE_TERMINATION_MODELS) + ]) + + class CableForm(TenancyForm, NetBoxModelForm): + a_terminations_type = forms.ChoiceField( + choices=get_termination_type_choices, + required=False, + widget=HTMXSelect(), + label=_('Type') + ) + b_terminations_type = forms.ChoiceField( + choices=get_termination_type_choices, + required=False, + widget=HTMXSelect(), + label=_('Type') + ) comments = CommentField() class Meta: model = Cable fields = [ - 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', - 'comments', 'tags', + 'a_terminations_type', 'b_terminations_type', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', + 'length', 'length_unit', 'description', 'comments', 'tags', ] error_messages = { 'length': { @@ -649,7 +680,7 @@ class PowerPanelForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Power Panel', ('site', 'location', 'name', 'description', 'tags')), + FieldSet('site', 'location', 'name', 'description', 'tags', name=_('Power Panel')), ) class Meta: @@ -674,9 +705,12 @@ class PowerFeedForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Power Feed'), ('power_panel', 'rack', 'name', 'status', 'type', 'description', 'mark_connected', 'tags')), - (_('Characteristics'), ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')), - (_('Tenancy'), ('tenant_group', 'tenant')), + FieldSet( + 'power_panel', 'rack', 'name', 'status', 'type', 'description', 'mark_connected', 'tags', + name=_('Power Feed') + ), + FieldSet('supply', 'voltage', 'amperage', 'phase', 'max_utilization', name=_('Characteristics')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) class Meta: @@ -755,7 +789,7 @@ class DeviceVCMembershipForm(forms.ModelForm): return vc_position -class VCMemberSelectForm(BootstrapMixin, forms.Form): +class VCMemberSelectForm(forms.Form): device = DynamicModelChoiceField( label=_('Device'), queryset=Device.objects.all(), @@ -778,10 +812,13 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form): # Device component templates # -class ComponentTemplateForm(BootstrapMixin, forms.ModelForm): +class ComponentTemplateForm(forms.ModelForm): device_type = DynamicModelChoiceField( label=_('Device type'), - queryset=DeviceType.objects.all() + queryset=DeviceType.objects.all(), + context={ + 'parent': 'manufacturer', + } ) def __init__(self, *args, **kwargs): @@ -796,12 +833,18 @@ class ModularComponentTemplateForm(ComponentTemplateForm): device_type = DynamicModelChoiceField( label=_('Device type'), queryset=DeviceType.objects.all().all(), - required=False + required=False, + context={ + 'parent': 'manufacturer', + } ) module_type = DynamicModelChoiceField( label=_('Module type'), queryset=ModuleType.objects.all(), - required=False + required=False, + context={ + 'parent': 'manufacturer', + } ) def __init__(self, *args, **kwargs): @@ -814,7 +857,7 @@ class ModularComponentTemplateForm(ComponentTemplateForm): class ConsolePortTemplateForm(ModularComponentTemplateForm): fieldsets = ( - (None, ('device_type', 'module_type', 'name', 'label', 'type', 'description')), + FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'description'), ) class Meta: @@ -826,7 +869,7 @@ class ConsolePortTemplateForm(ModularComponentTemplateForm): class ConsoleServerPortTemplateForm(ModularComponentTemplateForm): fieldsets = ( - (None, ('device_type', 'module_type', 'name', 'label', 'type', 'description')), + FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'description'), ) class Meta: @@ -838,9 +881,9 @@ class ConsoleServerPortTemplateForm(ModularComponentTemplateForm): class PowerPortTemplateForm(ModularComponentTemplateForm): fieldsets = ( - (None, ( + FieldSet( 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', - )), + ), ) class Meta: @@ -861,7 +904,7 @@ class PowerOutletTemplateForm(ModularComponentTemplateForm): ) fieldsets = ( - (None, ('device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description')), + FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description'), ) class Meta: @@ -883,9 +926,11 @@ class InterfaceTemplateForm(ModularComponentTemplateForm): ) fieldsets = ( - (None, ('device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'bridge')), - (_('PoE'), ('poe_mode', 'poe_type')), - (_('Wireless'), ('rf_role',)), + FieldSet( + 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'bridge', + ), + FieldSet('poe_mode', 'poe_type', name=_('PoE')), + FieldSet('rf_role', name=_('Wireless')), ) class Meta: @@ -907,10 +952,10 @@ class FrontPortTemplateForm(ModularComponentTemplateForm): ) fieldsets = ( - (None, ( + FieldSet( 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', - )), + ), ) class Meta: @@ -923,7 +968,7 @@ class FrontPortTemplateForm(ModularComponentTemplateForm): class RearPortTemplateForm(ModularComponentTemplateForm): fieldsets = ( - (None, ('device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description')), + FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description'), ) class Meta: @@ -935,7 +980,7 @@ class RearPortTemplateForm(ModularComponentTemplateForm): class ModuleBayTemplateForm(ComponentTemplateForm): fieldsets = ( - (None, ('device_type', 'name', 'label', 'position', 'description')), + FieldSet('device_type', 'name', 'label', 'position', 'description'), ) class Meta: @@ -947,7 +992,7 @@ class ModuleBayTemplateForm(ComponentTemplateForm): class DeviceBayTemplateForm(ComponentTemplateForm): fieldsets = ( - (None, ('device_type', 'name', 'label', 'description')), + FieldSet('device_type', 'name', 'label', 'description'), ) class Meta: @@ -976,31 +1021,128 @@ class InventoryItemTemplateForm(ComponentTemplateForm): queryset=Manufacturer.objects.all(), required=False ) - component_type = ContentTypeChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS, + + # Assigned component selectors + consoleporttemplate = DynamicModelChoiceField( + queryset=ConsolePortTemplate.objects.all(), required=False, - widget=forms.HiddenInput + query_params={ + 'device_type_id': '$device_type' + }, + label=_('Console port template') ) - component_id = forms.IntegerField( + consoleserverporttemplate = DynamicModelChoiceField( + queryset=ConsoleServerPortTemplate.objects.all(), required=False, - widget=forms.HiddenInput + query_params={ + 'device_type_id': '$device_type' + }, + label=_('Console server port template') + ) + frontporttemplate = DynamicModelChoiceField( + queryset=FrontPortTemplate.objects.all(), + required=False, + query_params={ + 'device_type_id': '$device_type' + }, + label=_('Front port template') + ) + interfacetemplate = DynamicModelChoiceField( + queryset=InterfaceTemplate.objects.all(), + required=False, + query_params={ + 'device_type_id': '$device_type' + }, + label=_('Interface template') + ) + poweroutlettemplate = DynamicModelChoiceField( + queryset=PowerOutletTemplate.objects.all(), + required=False, + query_params={ + 'device_type_id': '$device_type' + }, + label=_('Power outlet template') + ) + powerporttemplate = DynamicModelChoiceField( + queryset=PowerPortTemplate.objects.all(), + required=False, + query_params={ + 'device_type_id': '$device_type' + }, + label=_('Power port template') + ) + rearporttemplate = DynamicModelChoiceField( + queryset=RearPortTemplate.objects.all(), + required=False, + query_params={ + 'device_type_id': '$device_type' + }, + label=_('Rear port template') ) fieldsets = ( - (None, ( + FieldSet( 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description', - 'component_type', 'component_id', - )), + ), + FieldSet( + TabbedGroups( + FieldSet('interfacetemplate', name=_('Interface')), + FieldSet('consoleporttemplate', name=_('Console Port')), + FieldSet('consoleserverporttemplate', name=_('Console Server Port')), + FieldSet('frontporttemplate', name=_('Front Port')), + FieldSet('rearporttemplate', name=_('Rear Port')), + FieldSet('powerporttemplate', name=_('Power Port')), + FieldSet('poweroutlettemplate', name=_('Power Outlet')), + ), + name=_('Component Assignment') + ) ) class Meta: model = InventoryItemTemplate fields = [ 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description', - 'component_type', 'component_id', ] + def __init__(self, *args, **kwargs): + instance = kwargs.get('instance') + initial = kwargs.get('initial', {}).copy() + component_type = initial.get('component_type') + component_id = initial.get('component_id') + + if instance: + # When editing set the initial value for component selection + for component_model in ContentType.objects.filter(MODULAR_COMPONENT_TEMPLATE_MODELS): + if type(instance.component) is component_model.model_class(): + initial[component_model.model] = instance.component + break + elif component_type and component_id: + # When adding the InventoryItem from a component page + if content_type := ContentType.objects.filter(MODULAR_COMPONENT_TEMPLATE_MODELS).filter(pk=component_type).first(): + if component := content_type.model_class().objects.filter(pk=component_id).first(): + initial[content_type.model] = component + + kwargs['initial'] = initial + + super().__init__(*args, **kwargs) + + def clean(self): + super().clean() + + # Handle object assignment + selected_objects = [ + field for field in ( + 'consoleporttemplate', 'consoleserverporttemplate', 'frontporttemplate', 'interfacetemplate', + 'poweroutlettemplate', 'powerporttemplate', 'rearporttemplate' + ) if self.cleaned_data[field] + ] + if len(selected_objects) > 1: + raise forms.ValidationError(_("An InventoryItem can only be assigned to a single component.")) + elif selected_objects: + self.instance.component = self.cleaned_data[selected_objects[0]] + else: + self.instance.component = None + # # Device components @@ -1034,9 +1176,9 @@ class ModularDeviceComponentForm(DeviceComponentForm): class ConsolePortForm(ModularDeviceComponentForm): fieldsets = ( - (None, ( + FieldSet( 'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', - )), + ), ) class Meta: @@ -1047,11 +1189,10 @@ class ConsolePortForm(ModularDeviceComponentForm): class ConsoleServerPortForm(ModularDeviceComponentForm): - fieldsets = ( - (None, ( + FieldSet( 'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', - )), + ), ) class Meta: @@ -1062,12 +1203,11 @@ class ConsoleServerPortForm(ModularDeviceComponentForm): class PowerPortForm(ModularDeviceComponentForm): - fieldsets = ( - (None, ( + FieldSet( 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description', 'tags', - )), + ), ) class Meta: @@ -1089,10 +1229,10 @@ class PowerOutletForm(ModularDeviceComponentForm): ) fieldsets = ( - (None, ( + FieldSet( 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description', 'tags', - )), + ), ) class Meta: @@ -1188,15 +1328,18 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): ) fieldsets = ( - (_('Interface'), ('device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')), - (_('Addressing'), ('vrf', 'mac_address', 'wwn')), - (_('Operation'), ('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), - (_('Related Interfaces'), ('parent', 'bridge', 'lag')), - (_('PoE'), ('poe_mode', 'poe_type')), - (_('802.1Q Switching'), ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), - (_('Wireless'), ( + FieldSet( + 'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags', name=_('Interface') + ), + FieldSet('vrf', 'mac_address', 'wwn', name=_('Addressing')), + FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')), + FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')), + FieldSet('poe_mode', 'poe_type', name=_('PoE')), + FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', name=_('802.1Q Switching')), + FieldSet( 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans', - )), + name=_('Wireless') + ), ) class Meta: @@ -1227,10 +1370,10 @@ class FrontPortForm(ModularDeviceComponentForm): ) fieldsets = ( - (None, ( + FieldSet( 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected', 'description', 'tags', - )), + ), ) class Meta: @@ -1243,9 +1386,9 @@ class FrontPortForm(ModularDeviceComponentForm): class RearPortForm(ModularDeviceComponentForm): fieldsets = ( - (None, ( + FieldSet( 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags', - )), + ), ) class Meta: @@ -1257,7 +1400,7 @@ class RearPortForm(ModularDeviceComponentForm): class ModuleBayForm(DeviceComponentForm): fieldsets = ( - (None, ('device', 'name', 'label', 'position', 'description', 'tags',)), + FieldSet('device', 'name', 'label', 'position', 'description', 'tags',), ) class Meta: @@ -1269,7 +1412,7 @@ class ModuleBayForm(DeviceComponentForm): class DeviceBayForm(DeviceComponentForm): fieldsets = ( - (None, ('device', 'name', 'label', 'description', 'tags',)), + FieldSet('device', 'name', 'label', 'description', 'tags',), ) class Meta: @@ -1279,7 +1422,7 @@ class DeviceBayForm(DeviceComponentForm): ] -class PopulateDeviceBayForm(BootstrapMixin, forms.Form): +class PopulateDeviceBayForm(forms.Form): installed_device = forms.ModelChoiceField( queryset=Device.objects.all(), label=_('Child Device'), @@ -1377,8 +1520,20 @@ class InventoryItemForm(DeviceComponentForm): ) fieldsets = ( - (_('Inventory Item'), ('device', 'parent', 'name', 'label', 'role', 'description', 'tags')), - (_('Hardware'), ('manufacturer', 'part_id', 'serial', 'asset_tag')), + FieldSet('device', 'parent', 'name', 'label', 'role', 'description', 'tags', name=_('Inventory Item')), + FieldSet('manufacturer', 'part_id', 'serial', 'asset_tag', name=_('Hardware')), + FieldSet( + TabbedGroups( + FieldSet('interface', name=_('Interface')), + FieldSet('consoleport', name=_('Console Port')), + FieldSet('consoleserverport', name=_('Console Server Port')), + FieldSet('frontport', name=_('Front Port')), + FieldSet('rearport', name=_('Rear Port')), + FieldSet('powerport', name=_('Power Port')), + FieldSet('poweroutlet', name=_('Power Outlet')), + ), + name=_('Component Assignment') + ) ) class Meta: @@ -1394,22 +1549,17 @@ class InventoryItemForm(DeviceComponentForm): component_type = initial.get('component_type') component_id = initial.get('component_id') - # Used for picking the default active tab for component selection - self.no_component = True - if instance: - # When editing set the initial value for component selectin + # When editing set the initial value for component selection for component_model in ContentType.objects.filter(MODULAR_COMPONENT_MODELS): if type(instance.component) is component_model.model_class(): initial[component_model.model] = instance.component - self.no_component = False break elif component_type and component_id: # When adding the InventoryItem from a component page if content_type := ContentType.objects.filter(MODULAR_COMPONENT_MODELS).filter(pk=component_type).first(): if component := content_type.model_class().objects.filter(pk=component_id).first(): initial[content_type.model] = component - self.no_component = False kwargs['initial'] = initial @@ -1443,9 +1593,7 @@ class InventoryItemRoleForm(NetBoxModelForm): slug = SlugField() fieldsets = ( - (_('Inventory Item Role'), ( - 'name', 'slug', 'color', 'description', 'tags', - )), + FieldSet('name', 'slug', 'color', 'description', 'tags', name=_('Inventory Item Role')), ) class Meta: @@ -1481,8 +1629,11 @@ class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm): ) fieldsets = ( - (_('Virtual Device Context'), ('device', 'name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tags')), - (_('Tenancy'), ('tenant_group', 'tenant')) + FieldSet( + 'device', 'name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tags', + name=_('Virtual Device Context') + ), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')) ) class Meta: diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index ea842508f..f811700b4 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _ from dcim.models import * from netbox.forms import NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField +from utilities.forms.rendering import FieldSet from utilities.forms.widgets import APISelect from . import model_forms @@ -113,7 +114,7 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp # Override fieldsets from FrontPortTemplateForm to omit rear_port_position fieldsets = ( - (None, ('device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'description')), + FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'description'), ) class Meta(model_forms.FrontPortTemplateForm.Meta): @@ -274,9 +275,9 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm): # Override fieldsets from FrontPortForm to omit rear_port_position fieldsets = ( - (None, ( + FieldSet( 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'mark_connected', 'description', 'tags', - )), + ), ) class Meta(model_forms.FrontPortForm.Meta): diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py index bab8876da..d46ef83ad 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -3,7 +3,6 @@ from django.utils.translation import gettext_lazy as _ from dcim.choices import InterfacePoEModeChoices, InterfacePoETypeChoices, InterfaceTypeChoices, PortTypeChoices from dcim.models import * -from utilities.forms import BootstrapMixin from wireless.choices import WirelessRoleChoices __all__ = ( @@ -24,11 +23,7 @@ __all__ = ( # Component template import forms # -class ComponentTemplateImportForm(BootstrapMixin, forms.ModelForm): - pass - - -class ConsolePortTemplateImportForm(ComponentTemplateImportForm): +class ConsolePortTemplateImportForm(forms.ModelForm): class Meta: model = ConsolePortTemplate @@ -37,7 +32,7 @@ class ConsolePortTemplateImportForm(ComponentTemplateImportForm): ] -class ConsoleServerPortTemplateImportForm(ComponentTemplateImportForm): +class ConsoleServerPortTemplateImportForm(forms.ModelForm): class Meta: model = ConsoleServerPortTemplate @@ -46,7 +41,7 @@ class ConsoleServerPortTemplateImportForm(ComponentTemplateImportForm): ] -class PowerPortTemplateImportForm(ComponentTemplateImportForm): +class PowerPortTemplateImportForm(forms.ModelForm): class Meta: model = PowerPortTemplate @@ -55,7 +50,7 @@ class PowerPortTemplateImportForm(ComponentTemplateImportForm): ] -class PowerOutletTemplateImportForm(ComponentTemplateImportForm): +class PowerOutletTemplateImportForm(forms.ModelForm): power_port = forms.ModelChoiceField( label=_('Power port'), queryset=PowerPortTemplate.objects.all(), @@ -84,7 +79,7 @@ class PowerOutletTemplateImportForm(ComponentTemplateImportForm): return module_type -class InterfaceTemplateImportForm(ComponentTemplateImportForm): +class InterfaceTemplateImportForm(forms.ModelForm): type = forms.ChoiceField( label=_('Type'), choices=InterfaceTypeChoices.CHOICES @@ -113,7 +108,7 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm): ] -class FrontPortTemplateImportForm(ComponentTemplateImportForm): +class FrontPortTemplateImportForm(forms.ModelForm): type = forms.ChoiceField( label=_('Type'), choices=PortTypeChoices.CHOICES @@ -145,7 +140,7 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm): ] -class RearPortTemplateImportForm(ComponentTemplateImportForm): +class RearPortTemplateImportForm(forms.ModelForm): type = forms.ChoiceField( label=_('Type'), choices=PortTypeChoices.CHOICES @@ -158,7 +153,7 @@ class RearPortTemplateImportForm(ComponentTemplateImportForm): ] -class ModuleBayTemplateImportForm(ComponentTemplateImportForm): +class ModuleBayTemplateImportForm(forms.ModelForm): class Meta: model = ModuleBayTemplate @@ -167,7 +162,7 @@ class ModuleBayTemplateImportForm(ComponentTemplateImportForm): ] -class DeviceBayTemplateImportForm(ComponentTemplateImportForm): +class DeviceBayTemplateImportForm(forms.ModelForm): class Meta: model = DeviceBayTemplate @@ -176,7 +171,7 @@ class DeviceBayTemplateImportForm(ComponentTemplateImportForm): ] -class InventoryItemTemplateImportForm(ComponentTemplateImportForm): +class InventoryItemTemplateImportForm(forms.ModelForm): parent = forms.ModelChoiceField( label=_('Parent'), queryset=InventoryItemTemplate.objects.all(), diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py new file mode 100644 index 000000000..2ae5e7771 --- /dev/null +++ b/netbox/dcim/graphql/filters.py @@ -0,0 +1,294 @@ +import strawberry_django + +from dcim import filtersets, models +from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin + +__all__ = ( + 'CableFilter', + 'CableTerminationFilter', + 'ConsolePortFilter', + 'ConsolePortTemplateFilter', + 'ConsoleServerPortFilter', + 'ConsoleServerPortTemplateFilter', + 'DeviceFilter', + 'DeviceBayFilter', + 'DeviceBayTemplateFilter', + 'InventoryItemTemplateFilter', + 'DeviceRoleFilter', + 'DeviceTypeFilter', + 'FrontPortFilter', + 'FrontPortTemplateFilter', + 'InterfaceFilter', + 'InterfaceTemplateFilter', + 'InventoryItemFilter', + 'InventoryItemRoleFilter', + 'LocationFilter', + 'ManufacturerFilter', + 'ModuleFilter', + 'ModuleBayFilter', + 'ModuleBayTemplateFilter', + 'ModuleTypeFilter', + 'PlatformFilter', + 'PowerFeedFilter', + 'PowerOutletFilter', + 'PowerOutletTemplateFilter', + 'PowerPanelFilter', + 'PowerPortFilter', + 'PowerPortTemplateFilter', + 'RackFilter', + 'RackReservationFilter', + 'RackRoleFilter', + 'RearPortFilter', + 'RearPortTemplateFilter', + 'RegionFilter', + 'SiteFilter', + 'SiteGroupFilter', + 'VirtualChassisFilter', + 'VirtualDeviceContextFilter', +) + + +@strawberry_django.filter(models.Cable, lookups=True) +@autotype_decorator(filtersets.CableFilterSet) +class CableFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.CableTermination, lookups=True) +@autotype_decorator(filtersets.CableTerminationFilterSet) +class CableTerminationFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.ConsolePort, lookups=True) +@autotype_decorator(filtersets.ConsolePortFilterSet) +class ConsolePortFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.ConsolePortTemplate, lookups=True) +@autotype_decorator(filtersets.ConsolePortTemplateFilterSet) +class ConsolePortTemplateFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.ConsoleServerPort, lookups=True) +@autotype_decorator(filtersets.ConsoleServerPortFilterSet) +class ConsoleServerPortFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.ConsoleServerPortTemplate, lookups=True) +@autotype_decorator(filtersets.ConsoleServerPortTemplateFilterSet) +class ConsoleServerPortTemplateFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.Device, lookups=True) +@autotype_decorator(filtersets.DeviceFilterSet) +class DeviceFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.DeviceBay, lookups=True) +@autotype_decorator(filtersets.DeviceBayFilterSet) +class DeviceBayFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.DeviceBayTemplate, lookups=True) +@autotype_decorator(filtersets.DeviceBayTemplateFilterSet) +class DeviceBayTemplateFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.InventoryItemTemplate, lookups=True) +@autotype_decorator(filtersets.InventoryItemTemplateFilterSet) +class InventoryItemTemplateFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.DeviceRole, lookups=True) +@autotype_decorator(filtersets.DeviceRoleFilterSet) +class DeviceRoleFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.DeviceType, lookups=True) +@autotype_decorator(filtersets.DeviceTypeFilterSet) +class DeviceTypeFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.FrontPort, lookups=True) +@autotype_decorator(filtersets.FrontPortFilterSet) +class FrontPortFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.FrontPortTemplate, lookups=True) +@autotype_decorator(filtersets.FrontPortTemplateFilterSet) +class FrontPortTemplateFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.Interface, lookups=True) +@autotype_decorator(filtersets.InterfaceFilterSet) +class InterfaceFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.InterfaceTemplate, lookups=True) +@autotype_decorator(filtersets.InterfaceTemplateFilterSet) +class InterfaceTemplateFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.InventoryItem, lookups=True) +@autotype_decorator(filtersets.InventoryItemFilterSet) +class InventoryItemFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.InventoryItemRole, lookups=True) +@autotype_decorator(filtersets.InventoryItemRoleFilterSet) +class InventoryItemRoleFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.Location, lookups=True) +@autotype_decorator(filtersets.LocationFilterSet) +class LocationFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.Manufacturer, lookups=True) +@autotype_decorator(filtersets.ManufacturerFilterSet) +class ManufacturerFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.Module, lookups=True) +@autotype_decorator(filtersets.ModuleFilterSet) +class ModuleFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.ModuleBay, lookups=True) +@autotype_decorator(filtersets.ModuleBayFilterSet) +class ModuleBayFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.ModuleBayTemplate, lookups=True) +@autotype_decorator(filtersets.ModuleBayTemplateFilterSet) +class ModuleBayTemplateFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.ModuleType, lookups=True) +@autotype_decorator(filtersets.ModuleTypeFilterSet) +class ModuleTypeFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.Platform, lookups=True) +@autotype_decorator(filtersets.PlatformFilterSet) +class PlatformFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.PowerFeed, lookups=True) +@autotype_decorator(filtersets.PowerFeedFilterSet) +class PowerFeedFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.PowerOutlet, lookups=True) +@autotype_decorator(filtersets.PowerOutletFilterSet) +class PowerOutletFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.PowerOutletTemplate, lookups=True) +@autotype_decorator(filtersets.PowerOutletTemplateFilterSet) +class PowerOutletTemplateFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.PowerPanel, lookups=True) +@autotype_decorator(filtersets.PowerPanelFilterSet) +class PowerPanelFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.PowerPort, lookups=True) +@autotype_decorator(filtersets.PowerPortFilterSet) +class PowerPortFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.PowerPortTemplate, lookups=True) +@autotype_decorator(filtersets.PowerPortTemplateFilterSet) +class PowerPortTemplateFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.Rack, lookups=True) +@autotype_decorator(filtersets.RackFilterSet) +class RackFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.RackReservation, lookups=True) +@autotype_decorator(filtersets.RackReservationFilterSet) +class RackReservationFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.RackRole, lookups=True) +@autotype_decorator(filtersets.RackRoleFilterSet) +class RackRoleFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.RearPort, lookups=True) +@autotype_decorator(filtersets.RearPortFilterSet) +class RearPortFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.RearPortTemplate, lookups=True) +@autotype_decorator(filtersets.RearPortTemplateFilterSet) +class RearPortTemplateFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.Region, lookups=True) +@autotype_decorator(filtersets.RegionFilterSet) +class RegionFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.Site, lookups=True) +@autotype_decorator(filtersets.SiteFilterSet) +class SiteFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.SiteGroup, lookups=True) +@autotype_decorator(filtersets.SiteGroupFilterSet) +class SiteGroupFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.VirtualChassis, lookups=True) +@autotype_decorator(filtersets.VirtualChassisFilterSet) +class VirtualChassisFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.VirtualDeviceContext, lookups=True) +@autotype_decorator(filtersets.VirtualDeviceContextFilterSet) +class VirtualDeviceContextFilter(BaseFilterMixin): + pass diff --git a/netbox/dcim/graphql/gfk_mixins.py b/netbox/dcim/graphql/gfk_mixins.py index 2f669fb87..86ca88774 100644 --- a/netbox/dcim/graphql/gfk_mixins.py +++ b/netbox/dcim/graphql/gfk_mixins.py @@ -1,4 +1,3 @@ -import graphene from circuits.graphql.types import CircuitTerminationType, ProviderNetworkType from circuits.models import CircuitTermination, ProviderNetwork from dcim.graphql.types import ( @@ -37,79 +36,7 @@ from dcim.models import ( ) -class LinkPeerType(graphene.Union): - class Meta: - types = ( - CircuitTerminationType, - ConsolePortType, - ConsoleServerPortType, - FrontPortType, - InterfaceType, - PowerFeedType, - PowerOutletType, - PowerPortType, - RearPortType, - ) - - @classmethod - def resolve_type(cls, instance, info): - if type(instance) is CircuitTermination: - return CircuitTerminationType - if type(instance) is ConsolePortType: - return ConsolePortType - if type(instance) is ConsoleServerPort: - return ConsoleServerPortType - if type(instance) is FrontPort: - return FrontPortType - if type(instance) is Interface: - return InterfaceType - if type(instance) is PowerFeed: - return PowerFeedType - if type(instance) is PowerOutlet: - return PowerOutletType - if type(instance) is PowerPort: - return PowerPortType - if type(instance) is RearPort: - return RearPortType - - -class CableTerminationTerminationType(graphene.Union): - class Meta: - types = ( - CircuitTerminationType, - ConsolePortType, - ConsoleServerPortType, - FrontPortType, - InterfaceType, - PowerFeedType, - PowerOutletType, - PowerPortType, - RearPortType, - ) - - @classmethod - def resolve_type(cls, instance, info): - if type(instance) is CircuitTermination: - return CircuitTerminationType - if type(instance) is ConsolePortType: - return ConsolePortType - if type(instance) is ConsoleServerPort: - return ConsoleServerPortType - if type(instance) is FrontPort: - return FrontPortType - if type(instance) is Interface: - return InterfaceType - if type(instance) is PowerFeed: - return PowerFeedType - if type(instance) is PowerOutlet: - return PowerOutletType - if type(instance) is PowerPort: - return PowerPortType - if type(instance) is RearPort: - return RearPortType - - -class InventoryItemTemplateComponentType(graphene.Union): +class InventoryItemTemplateComponentType: class Meta: types = ( ConsolePortTemplateType, @@ -139,7 +66,7 @@ class InventoryItemTemplateComponentType(graphene.Union): return RearPortTemplateType -class InventoryItemComponentType(graphene.Union): +class InventoryItemComponentType: class Meta: types = ( ConsolePortType, @@ -169,7 +96,7 @@ class InventoryItemComponentType(graphene.Union): return RearPortType -class ConnectedEndpointType(graphene.Union): +class ConnectedEndpointType: class Meta: types = ( CircuitTerminationType, diff --git a/netbox/dcim/graphql/mixins.py b/netbox/dcim/graphql/mixins.py index 8241b7de5..589af50c8 100644 --- a/netbox/dcim/graphql/mixins.py +++ b/netbox/dcim/graphql/mixins.py @@ -1,20 +1,43 @@ -import graphene +from typing import Annotated, List, Union + +import strawberry +import strawberry_django + +__all__ = ( + 'CabledObjectMixin', + 'PathEndpointMixin', +) +@strawberry.type class CabledObjectMixin: - link_peers = graphene.List('dcim.graphql.gfk_mixins.LinkPeerType') + cable: Annotated["CableType", strawberry.lazy('dcim.graphql.types')] | None - def resolve_cable_end(self, info): - # Handle empty values - return self.cable_end or None - - def resolve_link_peers(self, info): - return self.link_peers + link_peers: List[Annotated[Union[ + Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')], + Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], + Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')], + ], strawberry.union("LinkPeerType")]] +@strawberry.type class PathEndpointMixin: - connected_endpoints = graphene.List('dcim.graphql.gfk_mixins.ConnectedEndpointType') - def resolve_connected_endpoints(self, info): - # Handle empty values - return self.connected_endpoints or None + connected_endpoints: List[Annotated[Union[ + Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')], + Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], + Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["ProviderNetworkType", strawberry.lazy('circuits.graphql.types')], + Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')], + ], strawberry.union("ConnectedEndpointType")]] diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index 6d689ac2d..c3962a87a 100644 --- a/netbox/dcim/graphql/schema.py +++ b/netbox/dcim/graphql/schema.py @@ -1,249 +1,210 @@ -import graphene +from typing import List + +import strawberry +import strawberry_django -from netbox.graphql.fields import ObjectField, ObjectListField -from .types import * from dcim import models -from .types import VirtualDeviceContextType -from utilities.graphql_optimizer import gql_query_optimizer - - -class DCIMQuery(graphene.ObjectType): - cable = ObjectField(CableType) - cable_list = ObjectListField(CableType) - - def resolve_cable_list(root, info, **kwargs): - return gql_query_optimizer(models.Cable.objects.all(), info) - - console_port = ObjectField(ConsolePortType) - console_port_list = ObjectListField(ConsolePortType) - - def resolve_console_port_list(root, info, **kwargs): - return gql_query_optimizer(models.ConsolePort.objects.all(), info) - - console_port_template = ObjectField(ConsolePortTemplateType) - console_port_template_list = ObjectListField(ConsolePortTemplateType) - - def resolve_console_port_template_list(root, info, **kwargs): - return gql_query_optimizer(models.ConsolePortTemplate.objects.all(), info) - - console_server_port = ObjectField(ConsoleServerPortType) - console_server_port_list = ObjectListField(ConsoleServerPortType) - - def resolve_console_server_port_list(root, info, **kwargs): - return gql_query_optimizer(models.ConsoleServerPort.objects.all(), info) - - console_server_port_template = ObjectField(ConsoleServerPortTemplateType) - console_server_port_template_list = ObjectListField(ConsoleServerPortTemplateType) - - def resolve_console_server_port_template_list(root, info, **kwargs): - return gql_query_optimizer(models.ConsoleServerPortTemplate.objects.all(), info) - - device = ObjectField(DeviceType) - device_list = ObjectListField(DeviceType) - - def resolve_device_list(root, info, **kwargs): - return gql_query_optimizer(models.Device.objects.all(), info) - - device_bay = ObjectField(DeviceBayType) - device_bay_list = ObjectListField(DeviceBayType) - - def resolve_device_bay_list(root, info, **kwargs): - return gql_query_optimizer(models.DeviceBay.objects.all(), info) - - device_bay_template = ObjectField(DeviceBayTemplateType) - device_bay_template_list = ObjectListField(DeviceBayTemplateType) - - def resolve_device_bay_template_list(root, info, **kwargs): - return gql_query_optimizer(models.DeviceBayTemplate.objects.all(), info) - - device_role = ObjectField(DeviceRoleType) - device_role_list = ObjectListField(DeviceRoleType) - - def resolve_device_role_list(root, info, **kwargs): - return gql_query_optimizer(models.DeviceRole.objects.all(), info) - - device_type = ObjectField(DeviceTypeType) - device_type_list = ObjectListField(DeviceTypeType) - - def resolve_device_type_list(root, info, **kwargs): - return gql_query_optimizer(models.DeviceType.objects.all(), info) - - front_port = ObjectField(FrontPortType) - front_port_list = ObjectListField(FrontPortType) - - def resolve_front_port_list(root, info, **kwargs): - return gql_query_optimizer(models.FrontPort.objects.all(), info) - - front_port_template = ObjectField(FrontPortTemplateType) - front_port_template_list = ObjectListField(FrontPortTemplateType) - - def resolve_front_port_template_list(root, info, **kwargs): - return gql_query_optimizer(models.FrontPortTemplate.objects.all(), info) - - interface = ObjectField(InterfaceType) - interface_list = ObjectListField(InterfaceType) - - def resolve_interface_list(root, info, **kwargs): - return gql_query_optimizer(models.Interface.objects.all(), info) - - interface_template = ObjectField(InterfaceTemplateType) - interface_template_list = ObjectListField(InterfaceTemplateType) - - def resolve_interface_template_list(root, info, **kwargs): - return gql_query_optimizer(models.InterfaceTemplate.objects.all(), info) - - inventory_item = ObjectField(InventoryItemType) - inventory_item_list = ObjectListField(InventoryItemType) - - def resolve_inventory_item_list(root, info, **kwargs): - return gql_query_optimizer(models.InventoryItem.objects.all(), info) - - inventory_item_role = ObjectField(InventoryItemRoleType) - inventory_item_role_list = ObjectListField(InventoryItemRoleType) - - def resolve_inventory_item_role_list(root, info, **kwargs): - return gql_query_optimizer(models.InventoryItemRole.objects.all(), info) - - inventory_item_template = ObjectField(InventoryItemTemplateType) - inventory_item_template_list = ObjectListField(InventoryItemTemplateType) - - def resolve_inventory_item_template_list(root, info, **kwargs): - return gql_query_optimizer(models.InventoryItemTemplate.objects.all(), info) - - location = ObjectField(LocationType) - location_list = ObjectListField(LocationType) - - def resolve_location_list(root, info, **kwargs): - return gql_query_optimizer(models.Location.objects.all(), info) - - manufacturer = ObjectField(ManufacturerType) - manufacturer_list = ObjectListField(ManufacturerType) - - def resolve_manufacturer_list(root, info, **kwargs): - return gql_query_optimizer(models.Manufacturer.objects.all(), info) - - module = ObjectField(ModuleType) - module_list = ObjectListField(ModuleType) - - def resolve_module_list(root, info, **kwargs): - return gql_query_optimizer(models.Module.objects.all(), info) - - module_bay = ObjectField(ModuleBayType) - module_bay_list = ObjectListField(ModuleBayType) - - def resolve_module_bay_list(root, info, **kwargs): - return gql_query_optimizer(models.ModuleBay.objects.all(), info) - - module_bay_template = ObjectField(ModuleBayTemplateType) - module_bay_template_list = ObjectListField(ModuleBayTemplateType) - - def resolve_module_bay_template_list(root, info, **kwargs): - return gql_query_optimizer(models.ModuleBayTemplate.objects.all(), info) - - module_type = ObjectField(ModuleTypeType) - module_type_list = ObjectListField(ModuleTypeType) - - def resolve_module_type_list(root, info, **kwargs): - return gql_query_optimizer(models.ModuleType.objects.all(), info) - - platform = ObjectField(PlatformType) - platform_list = ObjectListField(PlatformType) - - def resolve_platform_list(root, info, **kwargs): - return gql_query_optimizer(models.Platform.objects.all(), info) - - power_feed = ObjectField(PowerFeedType) - power_feed_list = ObjectListField(PowerFeedType) - - def resolve_power_feed_list(root, info, **kwargs): - return gql_query_optimizer(models.PowerFeed.objects.all(), info) - - power_outlet = ObjectField(PowerOutletType) - power_outlet_list = ObjectListField(PowerOutletType) - - def resolve_power_outlet_list(root, info, **kwargs): - return gql_query_optimizer(models.PowerOutlet.objects.all(), info) - - power_outlet_template = ObjectField(PowerOutletTemplateType) - power_outlet_template_list = ObjectListField(PowerOutletTemplateType) - - def resolve_power_outlet_template_list(root, info, **kwargs): - return gql_query_optimizer(models.PowerOutletTemplate.objects.all(), info) - - power_panel = ObjectField(PowerPanelType) - power_panel_list = ObjectListField(PowerPanelType) - - def resolve_power_panel_list(root, info, **kwargs): - return gql_query_optimizer(models.PowerPanel.objects.all(), info) - - power_port = ObjectField(PowerPortType) - power_port_list = ObjectListField(PowerPortType) - - def resolve_power_port_list(root, info, **kwargs): - return gql_query_optimizer(models.PowerPort.objects.all(), info) - - power_port_template = ObjectField(PowerPortTemplateType) - power_port_template_list = ObjectListField(PowerPortTemplateType) - - def resolve_power_port_template_list(root, info, **kwargs): - return gql_query_optimizer(models.PowerPortTemplate.objects.all(), info) - - rack = ObjectField(RackType) - rack_list = ObjectListField(RackType) - - def resolve_rack_list(root, info, **kwargs): - return gql_query_optimizer(models.Rack.objects.all(), info) - - rack_reservation = ObjectField(RackReservationType) - rack_reservation_list = ObjectListField(RackReservationType) - - def resolve_rack_reservation_list(root, info, **kwargs): - return gql_query_optimizer(models.RackReservation.objects.all(), info) - - rack_role = ObjectField(RackRoleType) - rack_role_list = ObjectListField(RackRoleType) - - def resolve_rack_role_list(root, info, **kwargs): - return gql_query_optimizer(models.RackRole.objects.all(), info) - - rear_port = ObjectField(RearPortType) - rear_port_list = ObjectListField(RearPortType) - - def resolve_rear_port_list(root, info, **kwargs): - return gql_query_optimizer(models.RearPort.objects.all(), info) - - rear_port_template = ObjectField(RearPortTemplateType) - rear_port_template_list = ObjectListField(RearPortTemplateType) - - def resolve_rear_port_template_list(root, info, **kwargs): - return gql_query_optimizer(models.RearPortTemplate.objects.all(), info) - - region = ObjectField(RegionType) - region_list = ObjectListField(RegionType) - - def resolve_region_list(root, info, **kwargs): - return gql_query_optimizer(models.Region.objects.all(), info) - - site = ObjectField(SiteType) - site_list = ObjectListField(SiteType) - - def resolve_site_list(root, info, **kwargs): - return gql_query_optimizer(models.Site.objects.all(), info) - - site_group = ObjectField(SiteGroupType) - site_group_list = ObjectListField(SiteGroupType) - - def resolve_site_group_list(root, info, **kwargs): - return gql_query_optimizer(models.SiteGroup.objects.all(), info) - - virtual_chassis = ObjectField(VirtualChassisType) - virtual_chassis_list = ObjectListField(VirtualChassisType) - - def resolve_virtual_chassis_list(root, info, **kwargs): - return gql_query_optimizer(models.VirtualChassis.objects.all(), info) - - virtual_device_context = ObjectField(VirtualDeviceContextType) - virtual_device_context_list = ObjectListField(VirtualDeviceContextType) - - def resolve_virtual_device_context_list(root, info, **kwargs): - return gql_query_optimizer(models.VirtualDeviceContext.objects.all(), info) +from .types import * + + +@strawberry.type +class DCIMQuery: + @strawberry.field + def cable(self, id: int) -> CableType: + return models.Cable.objects.get(pk=id) + cable_list: List[CableType] = strawberry_django.field() + + @strawberry.field + def console_port(self, id: int) -> ConsolePortType: + return models.ConsolePort.objects.get(pk=id) + console_port_list: List[ConsolePortType] = strawberry_django.field() + + @strawberry.field + def console_port_template(self, id: int) -> ConsolePortTemplateType: + return models.ConsolePortTemplate.objects.get(pk=id) + console_port_template_list: List[ConsolePortTemplateType] = strawberry_django.field() + + @strawberry.field + def console_server_port(self, id: int) -> ConsoleServerPortType: + return models.ConsoleServerPort.objects.get(pk=id) + console_server_port_list: List[ConsoleServerPortType] = strawberry_django.field() + + @strawberry.field + def console_server_port_template(self, id: int) -> ConsoleServerPortTemplateType: + return models.ConsoleServerPortTemplate.objects.get(pk=id) + console_server_port_template_list: List[ConsoleServerPortTemplateType] = strawberry_django.field() + + @strawberry.field + def device(self, id: int) -> DeviceType: + return models.Device.objects.get(pk=id) + device_list: List[DeviceType] = strawberry_django.field() + + @strawberry.field + def device_bay(self, id: int) -> DeviceBayType: + return models.DeviceBay.objects.get(pk=id) + device_bay_list: List[DeviceBayType] = strawberry_django.field() + + @strawberry.field + def device_bay_template(self, id: int) -> DeviceBayTemplateType: + return models.DeviceBayTemplate.objects.get(pk=id) + device_bay_template_list: List[DeviceBayTemplateType] = strawberry_django.field() + + @strawberry.field + def device_role(self, id: int) -> DeviceRoleType: + return models.DeviceRole.objects.get(pk=id) + device_role_list: List[DeviceRoleType] = strawberry_django.field() + + @strawberry.field + def device_type(self, id: int) -> DeviceTypeType: + return models.DeviceType.objects.get(pk=id) + device_type_list: List[DeviceTypeType] = strawberry_django.field() + + @strawberry.field + def front_port(self, id: int) -> FrontPortType: + return models.FrontPort.objects.get(pk=id) + front_port_list: List[FrontPortType] = strawberry_django.field() + + @strawberry.field + def front_port_template(self, id: int) -> FrontPortTemplateType: + return models.FrontPortTemplate.objects.get(pk=id) + front_port_template_list: List[FrontPortTemplateType] = strawberry_django.field() + + @strawberry.field + def interface(self, id: int) -> InterfaceType: + return models.Interface.objects.get(pk=id) + interface_list: List[InterfaceType] = strawberry_django.field() + + @strawberry.field + def interface_template(self, id: int) -> InterfaceTemplateType: + return models.InterfaceTemplate.objects.get(pk=id) + interface_template_list: List[InterfaceTemplateType] = strawberry_django.field() + + @strawberry.field + def inventory_item(self, id: int) -> InventoryItemType: + return models.InventoryItem.objects.get(pk=id) + inventory_item_list: List[InventoryItemType] = strawberry_django.field() + + @strawberry.field + def inventory_item_role(self, id: int) -> InventoryItemRoleType: + return models.InventoryItemRole.objects.get(pk=id) + inventory_item_role_list: List[InventoryItemRoleType] = strawberry_django.field() + + @strawberry.field + def inventory_item_template(self, id: int) -> InventoryItemTemplateType: + return models.InventoryItemTemplate.objects.get(pk=id) + inventory_item_template_list: List[InventoryItemTemplateType] = strawberry_django.field() + + @strawberry.field + def location(self, id: int) -> LocationType: + return models.Location.objects.get(pk=id) + location_list: List[LocationType] = strawberry_django.field() + + @strawberry.field + def manufacturer(self, id: int) -> ManufacturerType: + return models.Manufacturer.objects.get(pk=id) + manufacturer_list: List[ManufacturerType] = strawberry_django.field() + + @strawberry.field + def module(self, id: int) -> ModuleType: + return models.Module.objects.get(pk=id) + module_list: List[ModuleType] = strawberry_django.field() + + @strawberry.field + def module_bay(self, id: int) -> ModuleBayType: + return models.ModuleBay.objects.get(pk=id) + module_bay_list: List[ModuleBayType] = strawberry_django.field() + + @strawberry.field + def module_bay_template(self, id: int) -> ModuleBayTemplateType: + return models.ModuleBayTemplate.objects.get(pk=id) + module_bay_template_list: List[ModuleBayTemplateType] = strawberry_django.field() + + @strawberry.field + def module_type(self, id: int) -> ModuleTypeType: + return models.ModuleType.objects.get(pk=id) + module_type_list: List[ModuleTypeType] = strawberry_django.field() + + @strawberry.field + def platform(self, id: int) -> PlatformType: + return models.Platform.objects.get(pk=id) + platform_list: List[PlatformType] = strawberry_django.field() + + @strawberry.field + def power_feed(self, id: int) -> PowerFeedType: + return models.PowerFeed.objects.get(pk=id) + power_feed_list: List[PowerFeedType] = strawberry_django.field() + + @strawberry.field + def power_outlet(self, id: int) -> PowerOutletType: + return models.PowerOutlet.objects.get(pk=id) + power_outlet_list: List[PowerOutletType] = strawberry_django.field() + + @strawberry.field + def power_outlet_template(self, id: int) -> PowerOutletTemplateType: + return models.PowerOutletTemplate.objects.get(pk=id) + power_outlet_template_list: List[PowerOutletTemplateType] = strawberry_django.field() + + @strawberry.field + def power_panel(self, id: int) -> PowerPanelType: + return models.PowerPanel.objects.get(id=id) + power_panel_list: List[PowerPanelType] = strawberry_django.field() + + @strawberry.field + def power_port(self, id: int) -> PowerPortType: + return models.PowerPort.objects.get(id=id) + power_port_list: List[PowerPortType] = strawberry_django.field() + + @strawberry.field + def power_port_template(self, id: int) -> PowerPortTemplateType: + return models.PowerPortTemplate.objects.get(id=id) + power_port_template_list: List[PowerPortTemplateType] = strawberry_django.field() + + @strawberry.field + def rack(self, id: int) -> RackType: + return models.Rack.objects.get(id=id) + rack_list: List[RackType] = strawberry_django.field() + + @strawberry.field + def rack_reservation(self, id: int) -> RackReservationType: + return models.RackReservation.objects.get(id=id) + rack_reservation_list: List[RackReservationType] = strawberry_django.field() + + @strawberry.field + def rack_role(self, id: int) -> RackRoleType: + return models.RackRole.objects.get(id=id) + rack_role_list: List[RackRoleType] = strawberry_django.field() + + @strawberry.field + def rear_port(self, id: int) -> RearPortType: + return models.RearPort.objects.get(id=id) + rear_port_list: List[RearPortType] = strawberry_django.field() + + @strawberry.field + def rear_port_template(self, id: int) -> RearPortTemplateType: + return models.RearPortTemplate.objects.get(id=id) + rear_port_template_list: List[RearPortTemplateType] = strawberry_django.field() + + @strawberry.field + def region(self, id: int) -> RegionType: + return models.Region.objects.get(id=id) + region_list: List[RegionType] = strawberry_django.field() + + @strawberry.field + def site(self, id: int) -> SiteType: + return models.Site.objects.get(id=id) + site_list: List[SiteType] = strawberry_django.field() + + @strawberry.field + def site_group(self, id: int) -> SiteGroupType: + return models.SiteGroup.objects.get(id=id) + site_group_list: List[SiteGroupType] = strawberry_django.field() + + @strawberry.field + def virtual_chassis(self, id: int) -> VirtualChassisType: + return models.VirtualChassis.objects.get(id=id) + virtual_chassis_list: List[VirtualChassisType] = strawberry_django.field() + + @strawberry.field + def virtual_device_context(self, id: int) -> VirtualDeviceContextType: + return models.VirtualDeviceContext.objects.get(id=id) + virtual_device_context_list: List[VirtualDeviceContextType] = strawberry_django.field() diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 7d7434587..99a9106cb 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -1,17 +1,26 @@ -import graphene +from typing import Annotated, List, Union -from dcim import filtersets, models +import strawberry +import strawberry_django + +from dcim import models from extras.graphql.mixins import ( - ChangelogMixin, ConfigContextMixin, ContactsMixin, CustomFieldsMixin, ImageAttachmentsMixin, TagsMixin, + ChangelogMixin, + ConfigContextMixin, + ContactsMixin, + CustomFieldsMixin, + ImageAttachmentsMixin, + TagsMixin, ) from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin from netbox.graphql.scalars import BigInt -from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType +from netbox.graphql.types import BaseObjectType, NetBoxObjectType, OrganizationalObjectType +from .filters import * from .mixins import CabledObjectMixin, PathEndpointMixin __all__ = ( 'CableType', - 'ComponentObjectType', + 'ComponentType', 'ConsolePortType', 'ConsolePortTemplateType', 'ConsoleServerPortType', @@ -30,6 +39,7 @@ __all__ = ( 'InventoryItemTemplateType', 'LocationType', 'ManufacturerType', + 'ModularComponentType', 'ModuleType', 'ModuleBayType', 'ModuleBayTemplateType', @@ -50,6 +60,7 @@ __all__ = ( 'SiteType', 'SiteGroupType', 'VirtualChassisType', + 'VirtualDeviceContextType', ) @@ -58,7 +69,8 @@ __all__ = ( # -class ComponentObjectType( +@strawberry.type +class ComponentType( ChangelogMixin, CustomFieldsMixin, TagsMixin, @@ -67,447 +79,679 @@ class ComponentObjectType( """ Base type for device/VM components """ - class Meta: - abstract = True + _name: str + device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] -class ComponentTemplateObjectType( +@strawberry.type +class ModularComponentType(ComponentType): + module: Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')] | None + + +@strawberry.type +class ComponentTemplateType( ChangelogMixin, BaseObjectType ): """ Base type for device/VM components """ - class Meta: - abstract = True + _name: str + device_type: Annotated["DeviceTypeType", strawberry.lazy('dcim.graphql.types')] +@strawberry.type +class ModularComponentTemplateType(ComponentTemplateType): + """ + Base type for ComponentTemplateModel which supports optional assignment to a ModuleType. + """ + device_type: Annotated["DeviceTypeType", strawberry.lazy('dcim.graphql.types')] | None + module_type: Annotated["ModuleTypeType", strawberry.lazy('dcim.graphql.types')] | None + # # Model types # -class CableType(NetBoxObjectType): - a_terminations = graphene.List('dcim.graphql.gfk_mixins.CableTerminationTerminationType') - b_terminations = graphene.List('dcim.graphql.gfk_mixins.CableTerminationTerminationType') - - class Meta: - model = models.Cable - fields = '__all__' - filterset_class = filtersets.CableFilterSet - - def resolve_type(self, info): - return self.type or None - - def resolve_length_unit(self, info): - return self.length_unit or None - - def resolve_a_terminations(self, info): - return self.a_terminations - - def resolve_b_terminations(self, info): - return self.b_terminations - +@strawberry_django.type( + models.CableTermination, + exclude=('termination_type', 'termination_id'), + filters=CableTerminationFilter +) class CableTerminationType(NetBoxObjectType): - termination = graphene.Field('dcim.graphql.gfk_mixins.CableTerminationTerminationType') - class Meta: - model = models.CableTermination - exclude = ('termination_type', 'termination_id') - filterset_class = filtersets.CableTerminationFilterSet + termination: Annotated[Union[ + Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')], + Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], + Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')], + ], strawberry.union("CableTerminationTerminationType")] | None -class ConsolePortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin): +@strawberry_django.type( + models.Cable, + fields='__all__', + filters=CableFilter +) +class CableType(NetBoxObjectType): + color: str + tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None - class Meta: - model = models.ConsolePort - exclude = ('_path',) - filterset_class = filtersets.ConsolePortFilterSet + terminations: List[CableTerminationType] - def resolve_type(self, info): - return self.type or None + a_terminations: List[Annotated[Union[ + Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')], + Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], + Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')], + ], strawberry.union("CableTerminationTerminationType")]] + + b_terminations: List[Annotated[Union[ + Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')], + Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], + Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')], + ], strawberry.union("CableTerminationTerminationType")]] -class ConsolePortTemplateType(ComponentTemplateObjectType): - - class Meta: - model = models.ConsolePortTemplate - fields = '__all__' - filterset_class = filtersets.ConsolePortTemplateFilterSet - - def resolve_type(self, info): - return self.type or None +@strawberry_django.type( + models.ConsolePort, + exclude=('_path',), + filters=ConsolePortFilter +) +class ConsolePortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): + pass -class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin): - - class Meta: - model = models.ConsoleServerPort - exclude = ('_path',) - filterset_class = filtersets.ConsoleServerPortFilterSet - - def resolve_type(self, info): - return self.type or None +@strawberry_django.type( + models.ConsolePortTemplate, + fields='__all__', + filters=ConsolePortTemplateFilter +) +class ConsolePortTemplateType(ModularComponentTemplateType): + _name: str -class ConsoleServerPortTemplateType(ComponentTemplateObjectType): - - class Meta: - model = models.ConsoleServerPortTemplate - fields = '__all__' - filterset_class = filtersets.ConsoleServerPortTemplateFilterSet - - def resolve_type(self, info): - return self.type or None +@strawberry_django.type( + models.ConsoleServerPort, + exclude=('_path',), + filters=ConsoleServerPortFilter +) +class ConsoleServerPortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): + pass +@strawberry_django.type( + models.ConsoleServerPortTemplate, + fields='__all__', + filters=ConsoleServerPortTemplateFilter +) +class ConsoleServerPortTemplateType(ModularComponentTemplateType): + _name: str + + +@strawberry_django.type( + models.Device, + fields='__all__', + filters=DeviceFilter +) class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType): + _name: str + 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["ConfigTemplateType", strawberry.lazy('extras.graphql.types')] | None + device_type: Annotated["DeviceTypeType", strawberry.lazy('dcim.graphql.types')] + role: Annotated["DeviceRoleType", strawberry.lazy('dcim.graphql.types')] + tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None + platform: Annotated["PlatformType", strawberry.lazy('dcim.graphql.types')] | None + site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] + location: Annotated["LocationType", strawberry.lazy('dcim.graphql.types')] | None + rack: Annotated["RackType", strawberry.lazy('dcim.graphql.types')] | None + primary_ip4: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None + primary_ip6: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None + oob_ip: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None + cluster: Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')] | None + virtual_chassis: Annotated["VirtualChassisType", strawberry.lazy('dcim.graphql.types')] | None - class Meta: - model = models.Device - fields = '__all__' - filterset_class = filtersets.DeviceFilterSet + virtual_machines: List[Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]] + modules: List[Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')]] + interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]] + rearports: List[Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')]] + consoleports: List[Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')]] + powerports: List[Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')]] + cabletermination_set: List[Annotated["CableTerminationType", strawberry.lazy('dcim.graphql.types')]] + consoleserverports: List[Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')]] + poweroutlets: List[Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')]] + frontports: List[Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')]] + modulebays: List[Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')]] + services: List[Annotated["ServiceType", strawberry.lazy('ipam.graphql.types')]] + inventoryitems: List[Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')]] + vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]] - def resolve_face(self, info): - return self.face or None + @strawberry_django.field + def vc_master_for(self) -> Annotated["VirtualChassisType", strawberry.lazy('dcim.graphql.types')] | None: + return self.vc_master_for if hasattr(self, 'vc_master_for') else None - def resolve_airflow(self, info): - return self.airflow or None + @strawberry_django.field + def parent_bay(self) -> Annotated["DeviceBayType", strawberry.lazy('dcim.graphql.types')] | None: + return self.parent_bay if hasattr(self, 'parent_bay') else None -class DeviceBayType(ComponentObjectType): - - class Meta: - model = models.DeviceBay - fields = '__all__' - filterset_class = filtersets.DeviceBayFilterSet +@strawberry_django.type( + models.DeviceBay, + fields='__all__', + filters=DeviceBayFilter +) +class DeviceBayType(ComponentType): + installed_device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None -class DeviceBayTemplateType(ComponentTemplateObjectType): - - class Meta: - model = models.DeviceBayTemplate - fields = '__all__' - filterset_class = filtersets.DeviceBayTemplateFilterSet +@strawberry_django.type( + models.DeviceBayTemplate, + fields='__all__', + filters=DeviceBayTemplateFilter +) +class DeviceBayTemplateType(ComponentTemplateType): + _name: str -class InventoryItemTemplateType(ComponentTemplateObjectType): - component = graphene.Field('dcim.graphql.gfk_mixins.InventoryItemTemplateComponentType') +@strawberry_django.type( + models.InventoryItemTemplate, + exclude=('component_type', 'component_id', 'parent'), + filters=InventoryItemTemplateFilter +) +class InventoryItemTemplateType(ComponentTemplateType): + _name: str + role: Annotated["InventoryItemRoleType", strawberry.lazy('dcim.graphql.types')] | None + manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] - class Meta: - model = models.InventoryItemTemplate - exclude = ('component_type', 'component_id') - filterset_class = filtersets.InventoryItemTemplateFilterSet + @strawberry_django.field + def parent(self) -> Annotated["InventoryItemTemplateType", strawberry.lazy('dcim.graphql.types')] | None: + return self.parent + + child_items: List[Annotated["InventoryItemTemplateType", strawberry.lazy('dcim.graphql.types')]] + + component: Annotated[Union[ + Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], + Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')], + ], strawberry.union("InventoryItemTemplateComponentType")] | None +@strawberry_django.type( + models.DeviceRole, + fields='__all__', + filters=DeviceRoleFilter +) class DeviceRoleType(OrganizationalObjectType): + color: str + config_template: Annotated["ConfigTemplateType", strawberry.lazy('extras.graphql.types')] | None - class Meta: - model = models.DeviceRole - fields = '__all__' - filterset_class = filtersets.DeviceRoleFilterSet + virtual_machines: List[Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]] + devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] +@strawberry_django.type( + models.DeviceType, + fields='__all__', + filters=DeviceTypeFilter +) class DeviceTypeType(NetBoxObjectType): + 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["ManufacturerType", strawberry.lazy('dcim.graphql.types')] + default_platform: Annotated["PlatformType", strawberry.lazy('dcim.graphql.types')] | None - class Meta: - model = models.DeviceType - fields = '__all__' - filterset_class = filtersets.DeviceTypeFilterSet - - def resolve_subdevice_role(self, info): - return self.subdevice_role or None - - def resolve_airflow(self, info): - return self.airflow or None - - def resolve_weight_unit(self, info): - return self.weight_unit or None + frontporttemplates: List[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]] + modulebaytemplates: List[Annotated["ModuleBayTemplateType", strawberry.lazy('dcim.graphql.types')]] + instances: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] + poweroutlettemplates: List[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]] + powerporttemplates: List[Annotated["PowerPortTemplateType", strawberry.lazy('dcim.graphql.types')]] + inventoryitemtemplates: List[Annotated["InventoryItemTemplateType", strawberry.lazy('dcim.graphql.types')]] + rearporttemplates: List[Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')]] + consoleserverporttemplates: List[Annotated["ConsoleServerPortTemplateType", strawberry.lazy('dcim.graphql.types')]] + interfacetemplates: List[Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')]] + devicebaytemplates: List[Annotated["DeviceBayTemplateType", strawberry.lazy('dcim.graphql.types')]] + consoleporttemplates: List[Annotated["ConsolePortTemplateType", strawberry.lazy('dcim.graphql.types')]] -class FrontPortType(ComponentObjectType, CabledObjectMixin): - - class Meta: - model = models.FrontPort - fields = '__all__' - filterset_class = filtersets.FrontPortFilterSet +@strawberry_django.type( + models.FrontPort, + fields='__all__', + filters=FrontPortFilter +) +class FrontPortType(ModularComponentType, CabledObjectMixin): + color: str + rear_port: Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')] -class FrontPortTemplateType(ComponentTemplateObjectType): - - class Meta: - model = models.FrontPortTemplate - fields = '__all__' - filterset_class = filtersets.FrontPortTemplateFilterSet +@strawberry_django.type( + models.FrontPortTemplate, + fields='__all__', + filters=FrontPortTemplateFilter +) +class FrontPortTemplateType(ModularComponentTemplateType): + _name: str + color: str + rear_port: Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')] -class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin, PathEndpointMixin): +@strawberry_django.type( + models.Interface, + exclude=('_path',), + filters=InterfaceFilter +) +class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, PathEndpointMixin): + mac_address: str | None + wwn: str | None + parent: Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')] | None + bridge: Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')] | None + lag: Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')] | None + wireless_link: Annotated["WirelessLinkType", strawberry.lazy('wireless.graphql.types')] | None + untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None + vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None - class Meta: - model = models.Interface - exclude = ('_path',) - filterset_class = filtersets.InterfaceFilterSet - - def resolve_poe_mode(self, info): - return self.poe_mode or None - - def resolve_poe_type(self, info): - return self.poe_type or None - - def resolve_mode(self, info): - return self.mode or None - - def resolve_rf_role(self, info): - return self.rf_role or None - - def resolve_rf_channel(self, info): - return self.rf_channel or None + vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]] + tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]] + bridge_interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]] + wireless_lans: List[Annotated["WirelessLANType", strawberry.lazy('wireless.graphql.types')]] + member_interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]] + child_interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]] -class InterfaceTemplateType(ComponentTemplateObjectType): +@strawberry_django.type( + models.InterfaceTemplate, + fields='__all__', + filters=InterfaceTemplateFilter +) +class InterfaceTemplateType(ModularComponentTemplateType): + _name: str + bridge: Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')] | None - class Meta: - model = models.InterfaceTemplate - fields = '__all__' - filterset_class = filtersets.InterfaceTemplateFilterSet - - def resolve_poe_mode(self, info): - return self.poe_mode or None - - def resolve_poe_type(self, info): - return self.poe_type or None - - def resolve_rf_role(self, info): - return self.rf_role or None + bridge_interfaces: List[Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')]] -class InventoryItemType(ComponentObjectType): - component = graphene.Field('dcim.graphql.gfk_mixins.InventoryItemComponentType') +@strawberry_django.type( + models.InventoryItem, + exclude=('component_type', 'component_id', 'parent'), + filters=InventoryItemFilter +) +class InventoryItemType(ComponentType): + role: Annotated["InventoryItemRoleType", strawberry.lazy('dcim.graphql.types')] | None + manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] - class Meta: - model = models.InventoryItem - exclude = ('component_type', 'component_id') - filterset_class = filtersets.InventoryItemFilterSet + child_items: List[Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')]] + + @strawberry_django.field + def parent(self) -> Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')] | None: + return self.parent + + component: Annotated[Union[ + Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], + Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')], + ], strawberry.union("InventoryItemComponentType")] | None +@strawberry_django.type( + models.InventoryItemRole, + fields='__all__', + filters=InventoryItemRoleFilter +) class InventoryItemRoleType(OrganizationalObjectType): + color: str - class Meta: - model = models.InventoryItemRole - fields = '__all__' - filterset_class = filtersets.InventoryItemRoleFilterSet + inventory_items: List[Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')]] + inventory_item_templates: List[Annotated["InventoryItemTemplateType", strawberry.lazy('dcim.graphql.types')]] +@strawberry_django.type( + models.Location, + # fields='__all__', + exclude=('parent',), # bug - temp + filters=LocationFilter +) class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, OrganizationalObjectType): + site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] + tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None + parent: Annotated["LocationType", strawberry.lazy('dcim.graphql.types')] | None - class Meta: - model = models.Location - fields = '__all__' - filterset_class = filtersets.LocationFilterSet + powerpanel_set: List[Annotated["PowerPanelType", strawberry.lazy('dcim.graphql.types')]] + cabletermination_set: List[Annotated["CableTerminationType", strawberry.lazy('dcim.graphql.types')]] + racks: List[Annotated["RackType", strawberry.lazy('dcim.graphql.types')]] + devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] + children: List[Annotated["LocationType", strawberry.lazy('dcim.graphql.types')]] +@strawberry_django.type( + models.Manufacturer, + fields='__all__', + filters=ManufacturerFilter +) class ManufacturerType(OrganizationalObjectType, ContactsMixin): - class Meta: - model = models.Manufacturer - fields = '__all__' - filterset_class = filtersets.ManufacturerFilterSet + platforms: List[Annotated["PlatformType", strawberry.lazy('dcim.graphql.types')]] + device_types: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] + inventory_item_templates: List[Annotated["InventoryItemTemplateType", strawberry.lazy('dcim.graphql.types')]] + inventory_items: List[Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')]] + module_types: List[Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')]] -class ModuleType(ComponentObjectType): +@strawberry_django.type( + models.Module, + fields='__all__', + filters=ModuleFilter +) +class ModuleType(NetBoxObjectType): + device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] + module_bay: Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')] + module_type: Annotated["ModuleTypeType", strawberry.lazy('dcim.graphql.types')] - class Meta: - model = models.Module - fields = '__all__' - filterset_class = filtersets.ModuleFilterSet + interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]] + powerports: List[Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')]] + consoleserverports: List[Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')]] + consoleports: List[Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')]] + poweroutlets: List[Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')]] + rearports: List[Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')]] + frontports: List[Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')]] -class ModuleBayType(ComponentObjectType): +@strawberry_django.type( + models.ModuleBay, + fields='__all__', + filters=ModuleBayFilter +) +class ModuleBayType(ComponentType): - class Meta: - model = models.ModuleBay - fields = '__all__' - filterset_class = filtersets.ModuleBayFilterSet + installed_module: Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')] | None -class ModuleBayTemplateType(ComponentTemplateObjectType): - - class Meta: - model = models.ModuleBayTemplate - fields = '__all__' - filterset_class = filtersets.ModuleBayTemplateFilterSet +@strawberry_django.type( + models.ModuleBayTemplate, + fields='__all__', + filters=ModuleBayTemplateFilter +) +class ModuleBayTemplateType(ComponentTemplateType): + _name: str +@strawberry_django.type( + models.ModuleType, + fields='__all__', + filters=ModuleTypeFilter +) class ModuleTypeType(NetBoxObjectType): + manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] - class Meta: - model = models.ModuleType - fields = '__all__' - filterset_class = filtersets.ModuleTypeFilterSet - - def resolve_weight_unit(self, info): - return self.weight_unit or None + frontporttemplates: List[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]] + consoleserverporttemplates: List[Annotated["ConsoleServerPortTemplateType", strawberry.lazy('dcim.graphql.types')]] + interfacetemplates: List[Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')]] + powerporttemplates: List[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]] + poweroutlettemplates: List[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]] + rearporttemplates: List[Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')]] + instances: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]] + consoleporttemplates: List[Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')]] +@strawberry_django.type( + models.Platform, + fields='__all__', + filters=PlatformFilter +) class PlatformType(OrganizationalObjectType): + manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] | None + config_template: Annotated["ConfigTemplateType", strawberry.lazy('extras.graphql.types')] | None - class Meta: - model = models.Platform - fields = '__all__' - filterset_class = filtersets.PlatformFilterSet + virtual_machines: List[Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]] + devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] +@strawberry_django.type( + models.PowerFeed, + exclude=('_path',), + filters=PowerFeedFilter +) class PowerFeedType(NetBoxObjectType, CabledObjectMixin, PathEndpointMixin): - - class Meta: - model = models.PowerFeed - exclude = ('_path',) - filterset_class = filtersets.PowerFeedFilterSet + power_panel: Annotated["PowerPanelType", strawberry.lazy('dcim.graphql.types')] + rack: Annotated["RackType", strawberry.lazy('dcim.graphql.types')] | None + tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None -class PowerOutletType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin): - - class Meta: - model = models.PowerOutlet - exclude = ('_path',) - filterset_class = filtersets.PowerOutletFilterSet - - def resolve_feed_leg(self, info): - return self.feed_leg or None - - def resolve_type(self, info): - return self.type or None +@strawberry_django.type( + models.PowerOutlet, + exclude=('_path',), + filters=PowerOutletFilter +) +class PowerOutletType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): + power_port: Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')] | None -class PowerOutletTemplateType(ComponentTemplateObjectType): - - class Meta: - model = models.PowerOutletTemplate - fields = '__all__' - filterset_class = filtersets.PowerOutletTemplateFilterSet - - def resolve_feed_leg(self, info): - return self.feed_leg or None - - def resolve_type(self, info): - return self.type or None +@strawberry_django.type( + models.PowerOutletTemplate, + fields='__all__', + filters=PowerOutletTemplateFilter +) +class PowerOutletTemplateType(ModularComponentTemplateType): + _name: str + power_port: Annotated["PowerPortTemplateType", strawberry.lazy('dcim.graphql.types')] | None +@strawberry_django.type( + models.PowerPanel, + fields='__all__', + filters=PowerPanelFilter +) class PowerPanelType(NetBoxObjectType, ContactsMixin): + site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] + location: Annotated["LocationType", strawberry.lazy('dcim.graphql.types')] | None - class Meta: - model = models.PowerPanel - fields = '__all__' - filterset_class = filtersets.PowerPanelFilterSet + powerfeeds: List[Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')]] -class PowerPortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin): +@strawberry_django.type( + models.PowerPort, + exclude=('_path',), + filters=PowerPortFilter +) +class PowerPortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): - class Meta: - model = models.PowerPort - exclude = ('_path',) - filterset_class = filtersets.PowerPortFilterSet - - def resolve_type(self, info): - return self.type or None + poweroutlets: List[Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')]] -class PowerPortTemplateType(ComponentTemplateObjectType): +@strawberry_django.type( + models.PowerPortTemplate, + fields='__all__', + filters=PowerPortTemplateFilter +) +class PowerPortTemplateType(ModularComponentTemplateType): + _name: str - class Meta: - model = models.PowerPortTemplate - fields = '__all__' - filterset_class = filtersets.PowerPortTemplateFilterSet - - def resolve_type(self, info): - return self.type or None + poweroutlet_templates: List[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]] +@strawberry_django.type( + models.Rack, + fields='__all__', + filters=RackFilter +) class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType): + _name: str + site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] + location: Annotated["LocationType", strawberry.lazy('dcim.graphql.types')] | None + tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None + role: Annotated["RackRoleType", strawberry.lazy('dcim.graphql.types')] | None - class Meta: - model = models.Rack - fields = '__all__' - filterset_class = filtersets.RackFilterSet - - def resolve_type(self, info): - return self.type or None - - def resolve_outer_unit(self, info): - return self.outer_unit or None - - def resolve_weight_unit(self, info): - return self.weight_unit or None + reservations: List[Annotated["RackReservationType", strawberry.lazy('dcim.graphql.types')]] + devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] + powerfeeds: List[Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')]] + cabletermination_set: List[Annotated["CableTerminationType", strawberry.lazy('dcim.graphql.types')]] +@strawberry_django.type( + models.RackReservation, + fields='__all__', + filters=RackReservationFilter +) class RackReservationType(NetBoxObjectType): - - class Meta: - model = models.RackReservation - fields = '__all__' - filterset_class = filtersets.RackReservationFilterSet + units: List[int] + rack: Annotated["RackType", strawberry.lazy('dcim.graphql.types')] + tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None + user: Annotated["UserType", strawberry.lazy('users.graphql.types')] +@strawberry_django.type( + models.RackRole, + fields='__all__', + filters=RackRoleFilter +) class RackRoleType(OrganizationalObjectType): + color: str - class Meta: - model = models.RackRole - fields = '__all__' - filterset_class = filtersets.RackRoleFilterSet + racks: List[Annotated["RackType", strawberry.lazy('dcim.graphql.types')]] -class RearPortType(ComponentObjectType, CabledObjectMixin): +@strawberry_django.type( + models.RearPort, + fields='__all__', + filters=RearPortFilter +) +class RearPortType(ModularComponentType, CabledObjectMixin): + color: str - class Meta: - model = models.RearPort - fields = '__all__' - filterset_class = filtersets.RearPortFilterSet + frontports: List[Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')]] -class RearPortTemplateType(ComponentTemplateObjectType): +@strawberry_django.type( + models.RearPortTemplate, + fields='__all__', + filters=RearPortTemplateFilter +) +class RearPortTemplateType(ModularComponentTemplateType): + _name: str + color: str - class Meta: - model = models.RearPortTemplate - fields = '__all__' - filterset_class = filtersets.RearPortTemplateFilterSet + frontport_templates: List[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]] +@strawberry_django.type( + models.Region, + exclude=('parent',), + # fields='__all__', + filters=RegionFilter +) class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): - class Meta: - model = models.Region - fields = '__all__' - filterset_class = filtersets.RegionFilterSet + sites: List[Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]] + children: List[Annotated["RegionType", strawberry.lazy('dcim.graphql.types')]] + + @strawberry_django.field + def parent(self) -> Annotated["RegionType", strawberry.lazy('dcim.graphql.types')] | None: + return self.parent +@strawberry_django.type( + models.Site, + fields='__all__', + filters=SiteFilter +) class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType): - asn = graphene.Field(BigInt) + _name: str + time_zone: str | None + region: Annotated["RegionType", strawberry.lazy('dcim.graphql.types')] | None + group: Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')] | None + tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None - class Meta: - model = models.Site - fields = '__all__' - filterset_class = filtersets.SiteFilterSet + prefixes: List[Annotated["PrefixType", strawberry.lazy('ipam.graphql.types')]] + virtual_machines: List[Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]] + racks: List[Annotated["RackType", strawberry.lazy('dcim.graphql.types')]] + cabletermination_set: List[Annotated["CableTerminationType", strawberry.lazy('dcim.graphql.types')]] + powerpanel_set: List[Annotated["PowerPanelType", strawberry.lazy('dcim.graphql.types')]] + devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] + locations: List[Annotated["LocationType", strawberry.lazy('dcim.graphql.types')]] + asns: List[Annotated["ASNType", strawberry.lazy('ipam.graphql.types')]] + circuit_terminations: List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]] + clusters: List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]] + vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]] +@strawberry_django.type( + models.SiteGroup, + # fields='__all__', + exclude=('parent',), # bug - temp + filters=SiteGroupFilter +) class SiteGroupType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): - class Meta: - model = models.SiteGroup - fields = '__all__' - filterset_class = filtersets.SiteGroupFilterSet + sites: List[Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]] + children: List[Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')]] + + @strawberry_django.field + def parent(self) -> Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')] | None: + return self.parent +@strawberry_django.type( + models.VirtualChassis, + fields='__all__', + filters=VirtualChassisFilter +) class VirtualChassisType(NetBoxObjectType): + member_count: BigInt + master: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None - class Meta: - model = models.VirtualChassis - fields = '__all__' - filterset_class = filtersets.VirtualChassisFilterSet + members: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] +@strawberry_django.type( + models.VirtualDeviceContext, + fields='__all__', + filters=VirtualDeviceContextFilter +) class VirtualDeviceContextType(NetBoxObjectType): + device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None + primary_ip4: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None + primary_ip6: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None + tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None - class Meta: - model = models.VirtualDeviceContext - fields = '__all__' - filterset_class = filtersets.VirtualDeviceContextFilterSet + interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]] diff --git a/netbox/dcim/management/commands/trace_paths.py b/netbox/dcim/management/commands/trace_paths.py index 4bb81bfd4..d34a428e4 100644 --- a/netbox/dcim/management/commands/trace_paths.py +++ b/netbox/dcim/management/commands/trace_paths.py @@ -34,7 +34,7 @@ class Command(BaseCommand): Draw a simple progress bar 20 increments wide illustrating the specified percentage. """ bar_size = int(percentage / 5) - self.stdout.write(f"\r [{'#' * bar_size}{' ' * (20-bar_size)}] {int(percentage)}%", ending='') + self.stdout.write(f"\r [{'#' * bar_size}{' ' * (20 - bar_size)}] {int(percentage)}%", ending='') def handle(self, *model_names, **options): diff --git a/netbox/dcim/migrations/0002_squashed.py b/netbox/dcim/migrations/0002_squashed.py index c7325210e..786167680 100644 --- a/netbox/dcim/migrations/0002_squashed.py +++ b/netbox/dcim/migrations/0002_squashed.py @@ -233,7 +233,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='powerfeed', name='rack', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.rack'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='powerfeeds', to='dcim.rack'), ), migrations.AddField( model_name='powerfeed', diff --git a/netbox/dcim/migrations/0131_consoleport_speed.py b/netbox/dcim/migrations/0131_consoleport_speed.py deleted file mode 100644 index 350162218..000000000 --- a/netbox/dcim/migrations/0131_consoleport_speed.py +++ /dev/null @@ -1,21 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0130_sitegroup'), - ] - - operations = [ - migrations.AlterField( - model_name='consoleport', - name='speed', - field=models.PositiveIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='consoleserverport', - name='speed', - field=models.PositiveIntegerField(blank=True, null=True), - ), - ] diff --git a/netbox/dcim/migrations/0131_squashed_0159.py b/netbox/dcim/migrations/0131_squashed_0159.py new file mode 100644 index 000000000..f7e7cfdb2 --- /dev/null +++ b/netbox/dcim/migrations/0131_squashed_0159.py @@ -0,0 +1,1194 @@ +import dcim.fields +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import mptt.fields +import taggit.managers +import utilities.fields +import utilities.json +import utilities.ordering + + +class Migration(migrations.Migration): + + replaces = [ + ('dcim', '0131_consoleport_speed'), + ('dcim', '0132_cable_length'), + ('dcim', '0133_port_colors'), + ('dcim', '0134_interface_wwn_bridge'), + ('dcim', '0135_tenancy_extensions'), + ('dcim', '0136_device_airflow'), + ('dcim', '0137_relax_uniqueness_constraints'), + ('dcim', '0138_extend_tag_support'), + ('dcim', '0139_rename_cable_peer'), + ('dcim', '0140_wireless'), + ('dcim', '0141_asn_model'), + ('dcim', '0142_rename_128gfc_qsfp28'), + ('dcim', '0143_remove_primary_for_related_name'), + ('dcim', '0144_fix_cable_abs_length'), + ('dcim', '0145_site_remove_deprecated_fields'), + ('dcim', '0146_modules'), + ('dcim', '0147_inventoryitemrole'), + ('dcim', '0148_inventoryitem_component'), + ('dcim', '0149_inventoryitem_templates'), + ('dcim', '0150_interface_vrf'), + ('dcim', '0151_interface_speed_duplex'), + ('dcim', '0152_standardize_id_fields'), + ('dcim', '0153_created_datetimefield'), + ('dcim', '0154_half_height_rack_units'), + ('dcim', '0155_interface_poe_mode_type'), + ('dcim', '0156_location_status'), + ('dcim', '0157_new_cabling_models'), + ('dcim', '0158_populate_cable_terminations'), + ('dcim', '0159_populate_cable_paths') + ] + + dependencies = [ + ('tenancy', '0012_standardize_models'), + ('extras', '0002_squashed_0059'), + ('dcim', '0130_sitegroup'), + ('contenttypes', '0002_remove_content_type_name'), + ('ipam', '0053_asn_model'), + ('wireless', '0001_wireless'), + ] + + operations = [ + migrations.AlterField( + model_name='consoleport', + name='speed', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='consoleserverport', + name='speed', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='cable', + name='length', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True), + ), + migrations.AddField( + model_name='frontport', + name='color', + field=utilities.fields.ColorField(blank=True, max_length=6), + ), + migrations.AddField( + model_name='frontporttemplate', + name='color', + field=utilities.fields.ColorField(blank=True, max_length=6), + ), + migrations.AddField( + model_name='rearport', + name='color', + field=utilities.fields.ColorField(blank=True, max_length=6), + ), + migrations.AddField( + model_name='rearporttemplate', + name='color', + field=utilities.fields.ColorField(blank=True, max_length=6), + ), + migrations.AddField( + model_name='interface', + name='wwn', + field=dcim.fields.WWNField(blank=True, null=True), + ), + migrations.AddField( + model_name='interface', + name='bridge', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bridge_interfaces', to='dcim.interface'), + ), + migrations.AddField( + model_name='location', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='locations', to='tenancy.tenant'), + ), + migrations.AddField( + model_name='cable', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='cables', to='tenancy.tenant'), + ), + migrations.AddField( + model_name='devicetype', + name='airflow', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='device', + name='airflow', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AlterField( + model_name='region', + name='name', + field=models.CharField(max_length=100), + ), + migrations.AlterField( + model_name='region', + name='slug', + field=models.SlugField(max_length=100), + ), + migrations.AlterField( + model_name='sitegroup', + name='name', + field=models.CharField(max_length=100), + ), + migrations.AlterField( + model_name='sitegroup', + name='slug', + field=models.SlugField(max_length=100), + ), + migrations.AlterUniqueTogether( + name='location', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='location', + constraint=models.UniqueConstraint(fields=('site', 'parent', 'name'), name='dcim_location_parent_name'), + ), + migrations.AddConstraint( + model_name='location', + constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('site', 'name'), name='dcim_location_name'), + ), + migrations.AddConstraint( + model_name='location', + constraint=models.UniqueConstraint(fields=('site', 'parent', 'slug'), name='dcim_location_parent_slug'), + ), + migrations.AddConstraint( + model_name='location', + constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('site', 'slug'), name='dcim_location_slug'), + ), + migrations.AddConstraint( + model_name='region', + constraint=models.UniqueConstraint(fields=('parent', 'name'), name='dcim_region_parent_name'), + ), + migrations.AddConstraint( + model_name='region', + constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('name',), name='dcim_region_name'), + ), + migrations.AddConstraint( + model_name='region', + constraint=models.UniqueConstraint(fields=('parent', 'slug'), name='dcim_region_parent_slug'), + ), + migrations.AddConstraint( + model_name='region', + constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('slug',), name='dcim_region_slug'), + ), + migrations.AddConstraint( + model_name='sitegroup', + constraint=models.UniqueConstraint(fields=('parent', 'name'), name='dcim_sitegroup_parent_name'), + ), + migrations.AddConstraint( + model_name='sitegroup', + constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('name',), name='dcim_sitegroup_name'), + ), + migrations.AddConstraint( + model_name='sitegroup', + constraint=models.UniqueConstraint(fields=('parent', 'slug'), name='dcim_sitegroup_parent_slug'), + ), + migrations.AddConstraint( + model_name='sitegroup', + constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('slug',), name='dcim_sitegroup_slug'), + ), + migrations.AddField( + model_name='devicerole', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='location', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='manufacturer', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='platform', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='rackrole', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='region', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='sitegroup', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.RenameField( + model_name='consoleport', + old_name='_cable_peer_id', + new_name='_link_peer_id', + ), + migrations.RenameField( + model_name='consoleport', + old_name='_cable_peer_type', + new_name='_link_peer_type', + ), + migrations.RenameField( + model_name='consoleserverport', + old_name='_cable_peer_id', + new_name='_link_peer_id', + ), + migrations.RenameField( + model_name='consoleserverport', + old_name='_cable_peer_type', + new_name='_link_peer_type', + ), + migrations.RenameField( + model_name='frontport', + old_name='_cable_peer_id', + new_name='_link_peer_id', + ), + migrations.RenameField( + model_name='frontport', + old_name='_cable_peer_type', + new_name='_link_peer_type', + ), + migrations.RenameField( + model_name='interface', + old_name='_cable_peer_id', + new_name='_link_peer_id', + ), + migrations.RenameField( + model_name='interface', + old_name='_cable_peer_type', + new_name='_link_peer_type', + ), + migrations.RenameField( + model_name='powerfeed', + old_name='_cable_peer_id', + new_name='_link_peer_id', + ), + migrations.RenameField( + model_name='powerfeed', + old_name='_cable_peer_type', + new_name='_link_peer_type', + ), + migrations.RenameField( + model_name='poweroutlet', + old_name='_cable_peer_id', + new_name='_link_peer_id', + ), + migrations.RenameField( + model_name='poweroutlet', + old_name='_cable_peer_type', + new_name='_link_peer_type', + ), + migrations.RenameField( + model_name='powerport', + old_name='_cable_peer_id', + new_name='_link_peer_id', + ), + migrations.RenameField( + model_name='powerport', + old_name='_cable_peer_type', + new_name='_link_peer_type', + ), + migrations.RenameField( + model_name='rearport', + old_name='_cable_peer_id', + new_name='_link_peer_id', + ), + migrations.RenameField( + model_name='rearport', + old_name='_cable_peer_type', + new_name='_link_peer_type', + ), + migrations.AddField( + model_name='interface', + name='rf_role', + field=models.CharField(blank=True, max_length=30), + ), + migrations.AddField( + model_name='interface', + name='rf_channel', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='interface', + name='rf_channel_frequency', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True), + ), + migrations.AddField( + model_name='interface', + name='rf_channel_width', + field=models.DecimalField(blank=True, decimal_places=3, max_digits=7, null=True), + ), + migrations.AddField( + model_name='interface', + name='tx_power', + field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(127)]), + ), + migrations.AddField( + model_name='interface', + name='wireless_lans', + field=models.ManyToManyField(blank=True, related_name='interfaces', to='wireless.wirelesslan'), + ), + migrations.AddField( + model_name='interface', + name='wireless_link', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wireless.wirelesslink'), + ), + migrations.AddField( + model_name='site', + name='asns', + field=models.ManyToManyField(blank=True, related_name='sites', to='ipam.asn'), + ), + migrations.AlterField( + model_name='device', + name='primary_ip4', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress'), + ), + migrations.AlterField( + model_name='device', + name='primary_ip6', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress'), + ), + migrations.RemoveField( + model_name='site', + name='asn', + ), + migrations.RemoveField( + model_name='site', + name='contact_email', + ), + migrations.RemoveField( + model_name='site', + name='contact_name', + ), + migrations.RemoveField( + model_name='site', + name='contact_phone', + ), + migrations.RunSQL( + sql="\n DO $$\n DECLARE\n idx record;\n BEGIN\n FOR idx IN\n SELECT indexname AS old_name,\n replace(indexname, 'module', 'inventoryitem') AS new_name\n FROM pg_indexes\n WHERE schemaname = 'public' AND\n tablename = 'dcim_inventoryitem' AND\n indexname LIKE 'dcim_module_%'\n LOOP\n EXECUTE format(\n 'ALTER INDEX %I RENAME TO %I;',\n idx.old_name,\n idx.new_name\n );\n END LOOP;\n END$$;\n ", + ), + migrations.AlterModelOptions( + name='consoleporttemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.AlterModelOptions( + name='consoleserverporttemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.AlterModelOptions( + name='frontporttemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.AlterModelOptions( + name='interfacetemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.AlterModelOptions( + name='poweroutlettemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.AlterModelOptions( + name='powerporttemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.AlterModelOptions( + name='rearporttemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.AlterField( + model_name='consoleporttemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), + ), + migrations.AlterField( + model_name='consoleserverporttemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), + ), + migrations.AlterField( + model_name='frontporttemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), + ), + migrations.AlterField( + model_name='poweroutlettemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), + ), + migrations.AlterField( + model_name='powerporttemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), + ), + migrations.AlterField( + model_name='rearporttemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), + ), + migrations.CreateModel( + name='ModuleType', + fields=[ + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('model', models.CharField(max_length=100)), + ('part_number', models.CharField(blank=True, max_length=50)), + ('comments', models.TextField(blank=True)), + ('manufacturer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='module_types', to='dcim.manufacturer')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ('manufacturer', 'model'), + 'unique_together': {('manufacturer', 'model')}, + }, + ), + migrations.CreateModel( + name='ModuleBay', + fields=[ + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), + ('label', models.CharField(blank=True, max_length=64)), + ('position', models.CharField(blank=True, max_length=30)), + ('description', models.CharField(blank=True, max_length=200)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ('device', '_name'), + 'unique_together': {('device', 'name')}, + }, + ), + migrations.CreateModel( + name='Module', + fields=[ + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('local_context_data', models.JSONField(blank=True, null=True)), + ('serial', models.CharField(blank=True, max_length=50)), + ('asset_tag', models.CharField(blank=True, max_length=50, null=True, unique=True)), + ('comments', models.TextField(blank=True)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modules', to='dcim.device')), + ('module_bay', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='installed_module', to='dcim.modulebay')), + ('module_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='instances', to='dcim.moduletype')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ('module_bay',), + }, + ), + migrations.AddField( + model_name='consoleport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), + ), + migrations.AddField( + model_name='consoleporttemplate', + name='module_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), + ), + migrations.AddField( + model_name='consoleserverport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), + ), + migrations.AddField( + model_name='consoleserverporttemplate', + name='module_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), + ), + migrations.AddField( + model_name='frontport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), + ), + migrations.AddField( + model_name='frontporttemplate', + name='module_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), + ), + migrations.AddField( + model_name='interface', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), + ), + migrations.AddField( + model_name='interfacetemplate', + name='module_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), + ), + migrations.AddField( + model_name='poweroutlet', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), + ), + migrations.AddField( + model_name='poweroutlettemplate', + name='module_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), + ), + migrations.AddField( + model_name='powerport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), + ), + migrations.AddField( + model_name='powerporttemplate', + name='module_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), + ), + migrations.AddField( + model_name='rearport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), + ), + migrations.AddField( + model_name='rearporttemplate', + name='module_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), + ), + migrations.AlterUniqueTogether( + name='consoleporttemplate', + unique_together={('device_type', 'name'), ('module_type', 'name')}, + ), + migrations.AlterUniqueTogether( + name='consoleserverporttemplate', + unique_together={('device_type', 'name'), ('module_type', 'name')}, + ), + migrations.AlterUniqueTogether( + name='frontporttemplate', + unique_together={('rear_port', 'rear_port_position'), ('device_type', 'name'), ('module_type', 'name')}, + ), + migrations.AlterUniqueTogether( + name='interfacetemplate', + unique_together={('device_type', 'name'), ('module_type', 'name')}, + ), + migrations.AlterUniqueTogether( + name='poweroutlettemplate', + unique_together={('device_type', 'name'), ('module_type', 'name')}, + ), + migrations.AlterUniqueTogether( + name='powerporttemplate', + unique_together={('device_type', 'name'), ('module_type', 'name')}, + ), + migrations.AlterUniqueTogether( + name='rearporttemplate', + unique_together={('device_type', 'name'), ('module_type', 'name')}, + ), + migrations.CreateModel( + name='InventoryItemRole', + fields=[ + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField(max_length=100, unique=True)), + ('color', utilities.fields.ColorField(default='9e9e9e', max_length=6)), + ('description', models.CharField(blank=True, max_length=200)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ('name',), + }, + ), + migrations.AddField( + model_name='inventoryitem', + name='role', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_items', to='dcim.inventoryitemrole'), + ), + migrations.AddField( + model_name='inventoryitem', + name='component_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='inventoryitem', + name='component_type', + field=models.ForeignKey(blank=True, limit_choices_to=models.Q(('app_label', 'dcim'), ('model__in', ('consoleport', 'consoleserverport', 'frontport', 'interface', 'poweroutlet', 'powerport', 'rearport'))), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype'), + ), + migrations.AddField( + model_name='interface', + name='vrf', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='interfaces', to='ipam.vrf'), + ), + migrations.AddField( + model_name='interface', + name='duplex', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AddField( + model_name='interface', + name='speed', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='cable', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='cablepath', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleport', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleporttemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleserverport', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleserverporttemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='device', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicebay', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicebaytemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicerole', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicetype', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='frontport', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='frontporttemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='interface', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='inventoryitem', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='location', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='manufacturer', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='platform', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerfeed', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='poweroutlet', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='poweroutlettemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerpanel', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerport', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerporttemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rack', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rackreservation', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rackrole', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rearport', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rearporttemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='region', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='site', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='sitegroup', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='virtualchassis', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='cable', + name='termination_a_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='cable', + name='termination_b_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='cablepath', + name='destination_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='cablepath', + name='origin_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='consoleport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='consoleserverport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='frontport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='interface', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='powerfeed', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='poweroutlet', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='powerport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='rearport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='cable', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='consoleport', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='consoleporttemplate', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='consoleserverport', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='consoleserverporttemplate', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='device', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='devicebay', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='devicebaytemplate', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='devicerole', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='devicetype', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='frontport', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='frontporttemplate', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='interface', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='inventoryitem', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.CreateModel( + name='InventoryItemTemplate', + fields=[ + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), + ('label', models.CharField(blank=True, max_length=64)), + ('description', models.CharField(blank=True, max_length=200)), + ('component_id', models.PositiveBigIntegerField(blank=True, null=True)), + ('part_id', models.CharField(blank=True, max_length=50)), + ('lft', models.PositiveIntegerField(editable=False)), + ('rght', models.PositiveIntegerField(editable=False)), + ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), + ('level', models.PositiveIntegerField(editable=False)), + ('component_type', models.ForeignKey(blank=True, limit_choices_to=models.Q(('app_label', 'dcim'), ('model__in', ('consoleporttemplate', 'consoleserverporttemplate', 'frontporttemplate', 'interfacetemplate', 'poweroutlettemplate', 'powerporttemplate', 'rearporttemplate'))), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), + ('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype')), + ('manufacturer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_item_templates', to='dcim.manufacturer')), + ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child_items', to='dcim.inventoryitemtemplate')), + ('role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_item_templates', to='dcim.inventoryitemrole')), + ], + options={ + 'ordering': ('device_type__id', 'parent__id', '_name'), + 'unique_together': {('device_type', 'parent', 'name')}, + }, + ), + migrations.AlterField( + model_name='location', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='manufacturer', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.CreateModel( + name='ModuleBayTemplate', + fields=[ + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), + ('label', models.CharField(blank=True, max_length=64)), + ('position', models.CharField(blank=True, max_length=30)), + ('description', models.CharField(blank=True, max_length=200)), + ('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype')), + ], + options={ + 'ordering': ('device_type', '_name'), + 'unique_together': {('device_type', 'name')}, + }, + ), + migrations.AlterField( + model_name='platform', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='powerfeed', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='poweroutlet', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='poweroutlettemplate', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='powerpanel', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='powerport', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='powerporttemplate', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='rack', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='rackreservation', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='rackrole', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='rearport', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='rearporttemplate', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='region', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='site', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='sitegroup', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='virtualchassis', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='devicetype', + name='u_height', + field=models.DecimalField(decimal_places=1, default=1.0, max_digits=4), + ), + migrations.AlterField( + model_name='device', + name='position', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100.5)]), + ), + migrations.AddField( + model_name='interface', + name='poe_mode', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='interface', + name='poe_type', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='interfacetemplate', + name='poe_mode', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='interfacetemplate', + name='poe_type', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='location', + name='status', + field=models.CharField(default='active', max_length=50), + ), + migrations.CreateModel( + name='CableTermination', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('cable_end', models.CharField(max_length=1)), + ('termination_id', models.PositiveBigIntegerField()), + ('cable', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='dcim.cable')), + ('termination_type', models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'circuits'), ('model__in', ('circuittermination',))), models.Q(('app_label', 'dcim'), ('model__in', ('consoleport', 'consoleserverport', 'frontport', 'interface', 'powerfeed', 'poweroutlet', 'powerport', 'rearport'))), _connector='OR')), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), + ('_device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.device')), + ('_rack', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.rack')), + ('_location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.location')), + ('_site', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.site')), + ], + options={ + 'ordering': ('cable', 'cable_end', 'pk'), + }, + ), + migrations.AddConstraint( + model_name='cabletermination', + constraint=models.UniqueConstraint(fields=('termination_type', 'termination_id'), name='dcim_cable_termination_unique_termination'), + ), + migrations.RenameField( + model_name='cablepath', + old_name='path', + new_name='_nodes', + ), + migrations.AddField( + model_name='cablepath', + name='path', + field=models.JSONField(default=list), + ), + migrations.AddField( + model_name='cablepath', + name='is_complete', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='consoleport', + name='cable_end', + field=models.CharField(blank=True, max_length=1), + ), + migrations.AddField( + model_name='consoleserverport', + name='cable_end', + field=models.CharField(blank=True, max_length=1), + ), + migrations.AddField( + model_name='frontport', + name='cable_end', + field=models.CharField(blank=True, max_length=1), + ), + migrations.AddField( + model_name='interface', + name='cable_end', + field=models.CharField(blank=True, max_length=1), + ), + migrations.AddField( + model_name='powerfeed', + name='cable_end', + field=models.CharField(blank=True, max_length=1), + ), + migrations.AddField( + model_name='poweroutlet', + name='cable_end', + field=models.CharField(blank=True, max_length=1), + ), + migrations.AddField( + model_name='powerport', + name='cable_end', + field=models.CharField(blank=True, max_length=1), + ), + migrations.AddField( + model_name='rearport', + name='cable_end', + field=models.CharField(blank=True, max_length=1), + ), + ] diff --git a/netbox/dcim/migrations/0132_cable_length.py b/netbox/dcim/migrations/0132_cable_length.py deleted file mode 100644 index e20a8b8aa..000000000 --- a/netbox/dcim/migrations/0132_cable_length.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0131_consoleport_speed'), - ] - - operations = [ - migrations.AlterField( - model_name='cable', - name='length', - field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True), - ), - ] diff --git a/netbox/dcim/migrations/0133_port_colors.py b/netbox/dcim/migrations/0133_port_colors.py deleted file mode 100644 index 8cae7ac8e..000000000 --- a/netbox/dcim/migrations/0133_port_colors.py +++ /dev/null @@ -1,32 +0,0 @@ -from django.db import migrations -import utilities.fields - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0132_cable_length'), - ] - - operations = [ - migrations.AddField( - model_name='frontport', - name='color', - field=utilities.fields.ColorField(blank=True, max_length=6), - ), - migrations.AddField( - model_name='frontporttemplate', - name='color', - field=utilities.fields.ColorField(blank=True, max_length=6), - ), - migrations.AddField( - model_name='rearport', - name='color', - field=utilities.fields.ColorField(blank=True, max_length=6), - ), - migrations.AddField( - model_name='rearporttemplate', - name='color', - field=utilities.fields.ColorField(blank=True, max_length=6), - ), - ] diff --git a/netbox/dcim/migrations/0134_interface_wwn_bridge.py b/netbox/dcim/migrations/0134_interface_wwn_bridge.py deleted file mode 100644 index a900ae6be..000000000 --- a/netbox/dcim/migrations/0134_interface_wwn_bridge.py +++ /dev/null @@ -1,23 +0,0 @@ -import dcim.fields -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0133_port_colors'), - ] - - operations = [ - migrations.AddField( - model_name='interface', - name='wwn', - field=dcim.fields.WWNField(blank=True, null=True), - ), - migrations.AddField( - model_name='interface', - name='bridge', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bridge_interfaces', to='dcim.interface'), - ), - ] diff --git a/netbox/dcim/migrations/0135_tenancy_extensions.py b/netbox/dcim/migrations/0135_tenancy_extensions.py deleted file mode 100644 index 96d765eea..000000000 --- a/netbox/dcim/migrations/0135_tenancy_extensions.py +++ /dev/null @@ -1,23 +0,0 @@ -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('tenancy', '0002_tenant_ordering'), - ('dcim', '0134_interface_wwn_bridge'), - ] - - operations = [ - migrations.AddField( - model_name='location', - name='tenant', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='locations', to='tenancy.tenant'), - ), - migrations.AddField( - model_name='cable', - name='tenant', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='cables', to='tenancy.tenant'), - ), - ] diff --git a/netbox/dcim/migrations/0136_device_airflow.py b/netbox/dcim/migrations/0136_device_airflow.py deleted file mode 100644 index 94cc89f3f..000000000 --- a/netbox/dcim/migrations/0136_device_airflow.py +++ /dev/null @@ -1,21 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0135_tenancy_extensions'), - ] - - operations = [ - migrations.AddField( - model_name='devicetype', - name='airflow', - field=models.CharField(blank=True, max_length=50), - ), - migrations.AddField( - model_name='device', - name='airflow', - field=models.CharField(blank=True, max_length=50), - ), - ] diff --git a/netbox/dcim/migrations/0137_relax_uniqueness_constraints.py b/netbox/dcim/migrations/0137_relax_uniqueness_constraints.py deleted file mode 100644 index 7cedb1b08..000000000 --- a/netbox/dcim/migrations/0137_relax_uniqueness_constraints.py +++ /dev/null @@ -1,83 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0136_device_airflow'), - ] - - operations = [ - migrations.AlterField( - model_name='region', - name='name', - field=models.CharField(max_length=100), - ), - migrations.AlterField( - model_name='region', - name='slug', - field=models.SlugField(max_length=100), - ), - migrations.AlterField( - model_name='sitegroup', - name='name', - field=models.CharField(max_length=100), - ), - migrations.AlterField( - model_name='sitegroup', - name='slug', - field=models.SlugField(max_length=100), - ), - migrations.AlterUniqueTogether( - name='location', - unique_together=set(), - ), - migrations.AddConstraint( - model_name='location', - constraint=models.UniqueConstraint(fields=('site', 'parent', 'name'), name='dcim_location_parent_name'), - ), - migrations.AddConstraint( - model_name='location', - constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('site', 'name'), name='dcim_location_name'), - ), - migrations.AddConstraint( - model_name='location', - constraint=models.UniqueConstraint(fields=('site', 'parent', 'slug'), name='dcim_location_parent_slug'), - ), - migrations.AddConstraint( - model_name='location', - constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('site', 'slug'), name='dcim_location_slug'), - ), - migrations.AddConstraint( - model_name='region', - constraint=models.UniqueConstraint(fields=('parent', 'name'), name='dcim_region_parent_name'), - ), - migrations.AddConstraint( - model_name='region', - constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('name',), name='dcim_region_name'), - ), - migrations.AddConstraint( - model_name='region', - constraint=models.UniqueConstraint(fields=('parent', 'slug'), name='dcim_region_parent_slug'), - ), - migrations.AddConstraint( - model_name='region', - constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('slug',), name='dcim_region_slug'), - ), - migrations.AddConstraint( - model_name='sitegroup', - constraint=models.UniqueConstraint(fields=('parent', 'name'), name='dcim_sitegroup_parent_name'), - ), - migrations.AddConstraint( - model_name='sitegroup', - constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('name',), name='dcim_sitegroup_name'), - ), - migrations.AddConstraint( - model_name='sitegroup', - constraint=models.UniqueConstraint(fields=('parent', 'slug'), name='dcim_sitegroup_parent_slug'), - ), - migrations.AddConstraint( - model_name='sitegroup', - constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('slug',), name='dcim_sitegroup_slug'), - ), - ] diff --git a/netbox/dcim/migrations/0138_extend_tag_support.py b/netbox/dcim/migrations/0138_extend_tag_support.py deleted file mode 100644 index 763b53c50..000000000 --- a/netbox/dcim/migrations/0138_extend_tag_support.py +++ /dev/null @@ -1,50 +0,0 @@ -# Generated by Django 3.2.8 on 2021-10-21 14:50 - -from django.db import migrations -import taggit.managers - - -class Migration(migrations.Migration): - - dependencies = [ - ('extras', '0062_clear_secrets_changelog'), - ('dcim', '0137_relax_uniqueness_constraints'), - ] - - operations = [ - migrations.AddField( - model_name='devicerole', - name='tags', - field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), - ), - migrations.AddField( - model_name='location', - name='tags', - field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), - ), - migrations.AddField( - model_name='manufacturer', - name='tags', - field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), - ), - migrations.AddField( - model_name='platform', - name='tags', - field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), - ), - migrations.AddField( - model_name='rackrole', - name='tags', - field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), - ), - migrations.AddField( - model_name='region', - name='tags', - field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), - ), - migrations.AddField( - model_name='sitegroup', - name='tags', - field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), - ), - ] diff --git a/netbox/dcim/migrations/0139_rename_cable_peer.py b/netbox/dcim/migrations/0139_rename_cable_peer.py deleted file mode 100644 index 59dc04e2a..000000000 --- a/netbox/dcim/migrations/0139_rename_cable_peer.py +++ /dev/null @@ -1,91 +0,0 @@ -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0138_extend_tag_support'), - ] - - operations = [ - migrations.RenameField( - model_name='consoleport', - old_name='_cable_peer_id', - new_name='_link_peer_id', - ), - migrations.RenameField( - model_name='consoleport', - old_name='_cable_peer_type', - new_name='_link_peer_type', - ), - migrations.RenameField( - model_name='consoleserverport', - old_name='_cable_peer_id', - new_name='_link_peer_id', - ), - migrations.RenameField( - model_name='consoleserverport', - old_name='_cable_peer_type', - new_name='_link_peer_type', - ), - migrations.RenameField( - model_name='frontport', - old_name='_cable_peer_id', - new_name='_link_peer_id', - ), - migrations.RenameField( - model_name='frontport', - old_name='_cable_peer_type', - new_name='_link_peer_type', - ), - migrations.RenameField( - model_name='interface', - old_name='_cable_peer_id', - new_name='_link_peer_id', - ), - migrations.RenameField( - model_name='interface', - old_name='_cable_peer_type', - new_name='_link_peer_type', - ), - migrations.RenameField( - model_name='powerfeed', - old_name='_cable_peer_id', - new_name='_link_peer_id', - ), - migrations.RenameField( - model_name='powerfeed', - old_name='_cable_peer_type', - new_name='_link_peer_type', - ), - migrations.RenameField( - model_name='poweroutlet', - old_name='_cable_peer_id', - new_name='_link_peer_id', - ), - migrations.RenameField( - model_name='poweroutlet', - old_name='_cable_peer_type', - new_name='_link_peer_type', - ), - migrations.RenameField( - model_name='powerport', - old_name='_cable_peer_id', - new_name='_link_peer_id', - ), - migrations.RenameField( - model_name='powerport', - old_name='_cable_peer_type', - new_name='_link_peer_type', - ), - migrations.RenameField( - model_name='rearport', - old_name='_cable_peer_id', - new_name='_link_peer_id', - ), - migrations.RenameField( - model_name='rearport', - old_name='_cable_peer_type', - new_name='_link_peer_type', - ), - ] diff --git a/netbox/dcim/migrations/0140_wireless.py b/netbox/dcim/migrations/0140_wireless.py deleted file mode 100644 index 430782cf0..000000000 --- a/netbox/dcim/migrations/0140_wireless.py +++ /dev/null @@ -1,49 +0,0 @@ -from django.db import migrations, models -import django.core.validators -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0139_rename_cable_peer'), - ('wireless', '0001_wireless'), - ] - - operations = [ - migrations.AddField( - model_name='interface', - name='rf_role', - field=models.CharField(blank=True, max_length=30), - ), - migrations.AddField( - model_name='interface', - name='rf_channel', - field=models.CharField(blank=True, max_length=50), - ), - migrations.AddField( - model_name='interface', - name='rf_channel_frequency', - field=models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True), - ), - migrations.AddField( - model_name='interface', - name='rf_channel_width', - field=models.DecimalField(blank=True, decimal_places=3, max_digits=7, null=True), - ), - migrations.AddField( - model_name='interface', - name='tx_power', - field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(127)]), - ), - migrations.AddField( - model_name='interface', - name='wireless_lans', - field=models.ManyToManyField(blank=True, related_name='interfaces', to='wireless.WirelessLAN'), - ), - migrations.AddField( - model_name='interface', - name='wireless_link', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wireless.wirelesslink'), - ), - ] diff --git a/netbox/dcim/migrations/0141_asn_model.py b/netbox/dcim/migrations/0141_asn_model.py deleted file mode 100644 index 6f011f35d..000000000 --- a/netbox/dcim/migrations/0141_asn_model.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.2.8 on 2021-11-02 16:16 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('ipam', '0053_asn_model'), - ('dcim', '0140_wireless'), - ] - - operations = [ - migrations.AddField( - model_name='site', - name='asns', - field=models.ManyToManyField(blank=True, related_name='sites', to='ipam.ASN'), - ), - ] diff --git a/netbox/dcim/migrations/0142_rename_128gfc_qsfp28.py b/netbox/dcim/migrations/0142_rename_128gfc_qsfp28.py deleted file mode 100644 index d1f91afae..000000000 --- a/netbox/dcim/migrations/0142_rename_128gfc_qsfp28.py +++ /dev/null @@ -1,29 +0,0 @@ -from django.db import migrations - -OLD_VALUE = '128gfc-sfp28' -NEW_VALUE = '128gfc-qsfp28' - - -def correct_type(apps, schema_editor): - """ - Correct TYPE_128GFC_QSFP28 interface type. - """ - Interface = apps.get_model('dcim', 'Interface') - InterfaceTemplate = apps.get_model('dcim', 'InterfaceTemplate') - - for model in (Interface, InterfaceTemplate): - model.objects.filter(type=OLD_VALUE).update(type=NEW_VALUE) - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0141_asn_model'), - ] - - operations = [ - migrations.RunPython( - code=correct_type, - reverse_code=migrations.RunPython.noop - ), - ] diff --git a/netbox/dcim/migrations/0143_remove_primary_for_related_name.py b/netbox/dcim/migrations/0143_remove_primary_for_related_name.py deleted file mode 100644 index 820c9e3fe..000000000 --- a/netbox/dcim/migrations/0143_remove_primary_for_related_name.py +++ /dev/null @@ -1,23 +0,0 @@ -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('ipam', '0053_asn_model'), - ('dcim', '0142_rename_128gfc_qsfp28'), - ] - - operations = [ - migrations.AlterField( - model_name='device', - name='primary_ip4', - field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress'), - ), - migrations.AlterField( - model_name='device', - name='primary_ip6', - field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress'), - ), - ] diff --git a/netbox/dcim/migrations/0144_fix_cable_abs_length.py b/netbox/dcim/migrations/0144_fix_cable_abs_length.py deleted file mode 100644 index 0da30ffb5..000000000 --- a/netbox/dcim/migrations/0144_fix_cable_abs_length.py +++ /dev/null @@ -1,31 +0,0 @@ -from django.db import migrations - -from utilities.utils import to_meters - - -def recalculate_abs_length(apps, schema_editor): - """ - Recalculate absolute lengths for all cables with a length and length unit defined. Fixes - incorrectly calculated values as reported under bug #8377. - """ - Cable = apps.get_model('dcim', 'Cable') - - cables = Cable.objects.filter(length__isnull=False).exclude(length_unit='') - for cable in cables: - cable._abs_length = to_meters(cable.length, cable.length_unit) - - Cable.objects.bulk_update(cables, ['_abs_length'], batch_size=100) - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0143_remove_primary_for_related_name'), - ] - - operations = [ - migrations.RunPython( - code=recalculate_abs_length, - reverse_code=migrations.RunPython.noop - ), - ] diff --git a/netbox/dcim/migrations/0145_site_remove_deprecated_fields.py b/netbox/dcim/migrations/0145_site_remove_deprecated_fields.py deleted file mode 100644 index 86918447d..000000000 --- a/netbox/dcim/migrations/0145_site_remove_deprecated_fields.py +++ /dev/null @@ -1,59 +0,0 @@ -import os - -from django.db import migrations -from django.db.utils import DataError - - -def check_legacy_data(apps, schema_editor): - """ - Abort the migration if any legacy site fields still contain data. - """ - Site = apps.get_model('dcim', 'Site') - - site_count = Site.objects.exclude(asn__isnull=True).count() - if site_count and 'NETBOX_DELETE_LEGACY_DATA' not in os.environ: - raise DataError( - f"Unable to proceed with deleting asn field from Site model: Found {site_count} sites with " - f"legacy ASN data. Please ensure all legacy site ASN data has been migrated to ASN objects " - f"before proceeding. Or, set the NETBOX_DELETE_LEGACY_DATA environment variable to bypass " - f"this safeguard and delete all legacy site ASN data." - ) - - site_count = Site.objects.exclude(contact_name='', contact_phone='', contact_email='').count() - if site_count and 'NETBOX_DELETE_LEGACY_DATA' not in os.environ: - raise DataError( - f"Unable to proceed with deleting contact fields from Site model: Found {site_count} sites " - f"with legacy contact data. Please ensure all legacy site contact data has been migrated to " - f"contact objects before proceeding. Or, set the NETBOX_DELETE_LEGACY_DATA environment " - f"variable to bypass this safeguard and delete all legacy site contact data." - ) - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0144_fix_cable_abs_length'), - ] - - operations = [ - migrations.RunPython( - code=check_legacy_data, - reverse_code=migrations.RunPython.noop - ), - migrations.RemoveField( - model_name='site', - name='asn', - ), - migrations.RemoveField( - model_name='site', - name='contact_email', - ), - migrations.RemoveField( - model_name='site', - name='contact_name', - ), - migrations.RemoveField( - model_name='site', - name='contact_phone', - ), - ] diff --git a/netbox/dcim/migrations/0146_modules.py b/netbox/dcim/migrations/0146_modules.py deleted file mode 100644 index 821cf6119..000000000 --- a/netbox/dcim/migrations/0146_modules.py +++ /dev/null @@ -1,279 +0,0 @@ -from utilities.json import CustomFieldJSONEncoder -from django.db import migrations, models -import django.db.models.deletion -import taggit.managers -import utilities.fields -import utilities.ordering - - -class Migration(migrations.Migration): - - dependencies = [ - ('extras', '0066_customfield_name_validation'), - ('dcim', '0145_site_remove_deprecated_fields'), - ] - - operations = [ - # Rename any indexes left over from the old Module model (now InventoryItem) (#8656) - migrations.RunSQL( - """ - DO $$ - DECLARE - idx record; - BEGIN - FOR idx IN - SELECT indexname AS old_name, - replace(indexname, 'module', 'inventoryitem') AS new_name - FROM pg_indexes - WHERE schemaname = 'public' AND - tablename = 'dcim_inventoryitem' AND - indexname LIKE 'dcim_module_%' - LOOP - EXECUTE format( - 'ALTER INDEX %I RENAME TO %I;', - idx.old_name, - idx.new_name - ); - END LOOP; - END$$; - """ - ), - - migrations.AlterModelOptions( - name='consoleporttemplate', - options={'ordering': ('device_type', 'module_type', '_name')}, - ), - migrations.AlterModelOptions( - name='consoleserverporttemplate', - options={'ordering': ('device_type', 'module_type', '_name')}, - ), - migrations.AlterModelOptions( - name='frontporttemplate', - options={'ordering': ('device_type', 'module_type', '_name')}, - ), - migrations.AlterModelOptions( - name='interfacetemplate', - options={'ordering': ('device_type', 'module_type', '_name')}, - ), - migrations.AlterModelOptions( - name='poweroutlettemplate', - options={'ordering': ('device_type', 'module_type', '_name')}, - ), - migrations.AlterModelOptions( - name='powerporttemplate', - options={'ordering': ('device_type', 'module_type', '_name')}, - ), - migrations.AlterModelOptions( - name='rearporttemplate', - options={'ordering': ('device_type', 'module_type', '_name')}, - ), - migrations.AlterField( - model_name='consoleporttemplate', - name='device_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), - ), - migrations.AlterField( - model_name='consoleserverporttemplate', - name='device_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), - ), - migrations.AlterField( - model_name='frontporttemplate', - name='device_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), - ), - migrations.AlterField( - model_name='interfacetemplate', - name='device_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), - ), - migrations.AlterField( - model_name='poweroutlettemplate', - name='device_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), - ), - migrations.AlterField( - model_name='powerporttemplate', - name='device_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), - ), - migrations.AlterField( - model_name='rearporttemplate', - name='device_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), - ), - migrations.CreateModel( - name='ModuleType', - fields=[ - ('created', models.DateField(auto_now_add=True, null=True)), - ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('model', models.CharField(max_length=100)), - ('part_number', models.CharField(blank=True, max_length=50)), - ('comments', models.TextField(blank=True)), - ('manufacturer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='module_types', to='dcim.manufacturer')), - ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), - ], - options={ - 'ordering': ('manufacturer', 'model'), - 'unique_together': {('manufacturer', 'model')}, - }, - ), - migrations.CreateModel( - name='ModuleBay', - fields=[ - ('created', models.DateField(auto_now_add=True, null=True)), - ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('name', models.CharField(max_length=64)), - ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), - ('label', models.CharField(blank=True, max_length=64)), - ('position', models.CharField(blank=True, max_length=30)), - ('description', models.CharField(blank=True, max_length=200)), - ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device')), - ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), - ], - options={ - 'ordering': ('device', '_name'), - 'unique_together': {('device', 'name')}, - }, - ), - migrations.CreateModel( - name='Module', - fields=[ - ('created', models.DateField(auto_now_add=True, null=True)), - ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('local_context_data', models.JSONField(blank=True, null=True)), - ('serial', models.CharField(blank=True, max_length=50)), - ('asset_tag', models.CharField(blank=True, max_length=50, null=True, unique=True)), - ('comments', models.TextField(blank=True)), - ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modules', to='dcim.device')), - ('module_bay', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='installed_module', to='dcim.modulebay')), - ('module_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='instances', to='dcim.moduletype')), - ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), - ], - options={ - 'ordering': ('module_bay',), - }, - ), - migrations.AddField( - model_name='consoleport', - name='module', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), - ), - migrations.AddField( - model_name='consoleporttemplate', - name='module_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), - ), - migrations.AddField( - model_name='consoleserverport', - name='module', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), - ), - migrations.AddField( - model_name='consoleserverporttemplate', - name='module_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), - ), - migrations.AddField( - model_name='frontport', - name='module', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), - ), - migrations.AddField( - model_name='frontporttemplate', - name='module_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), - ), - migrations.AddField( - model_name='interface', - name='module', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), - ), - migrations.AddField( - model_name='interfacetemplate', - name='module_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), - ), - migrations.AddField( - model_name='poweroutlet', - name='module', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), - ), - migrations.AddField( - model_name='poweroutlettemplate', - name='module_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), - ), - migrations.AddField( - model_name='powerport', - name='module', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), - ), - migrations.AddField( - model_name='powerporttemplate', - name='module_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), - ), - migrations.AddField( - model_name='rearport', - name='module', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), - ), - migrations.AddField( - model_name='rearporttemplate', - name='module_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), - ), - migrations.AlterUniqueTogether( - name='consoleporttemplate', - unique_together={('device_type', 'name'), ('module_type', 'name')}, - ), - migrations.AlterUniqueTogether( - name='consoleserverporttemplate', - unique_together={('device_type', 'name'), ('module_type', 'name')}, - ), - migrations.AlterUniqueTogether( - name='frontporttemplate', - unique_together={('device_type', 'name'), ('rear_port', 'rear_port_position'), ('module_type', 'name')}, - ), - migrations.AlterUniqueTogether( - name='interfacetemplate', - unique_together={('device_type', 'name'), ('module_type', 'name')}, - ), - migrations.AlterUniqueTogether( - name='poweroutlettemplate', - unique_together={('device_type', 'name'), ('module_type', 'name')}, - ), - migrations.AlterUniqueTogether( - name='powerporttemplate', - unique_together={('device_type', 'name'), ('module_type', 'name')}, - ), - migrations.AlterUniqueTogether( - name='rearporttemplate', - unique_together={('device_type', 'name'), ('module_type', 'name')}, - ), - migrations.CreateModel( - name='ModuleBayTemplate', - fields=[ - ('created', models.DateField(auto_now_add=True, null=True)), - ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('name', models.CharField(max_length=64)), - ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), - ('label', models.CharField(blank=True, max_length=64)), - ('position', models.CharField(blank=True, max_length=30)), - ('description', models.CharField(blank=True, max_length=200)), - ('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype')), - ], - options={ - 'ordering': ('device_type', '_name'), - 'unique_together': {('device_type', 'name')}, - }, - ), - ] diff --git a/netbox/dcim/migrations/0147_inventoryitemrole.py b/netbox/dcim/migrations/0147_inventoryitemrole.py deleted file mode 100644 index 4b6c27450..000000000 --- a/netbox/dcim/migrations/0147_inventoryitemrole.py +++ /dev/null @@ -1,38 +0,0 @@ -from utilities.json import CustomFieldJSONEncoder -from django.db import migrations, models -import django.db.models.deletion -import taggit.managers -import utilities.fields - - -class Migration(migrations.Migration): - - dependencies = [ - ('extras', '0068_configcontext_cluster_types'), - ('dcim', '0146_modules'), - ] - - operations = [ - migrations.CreateModel( - name='InventoryItemRole', - fields=[ - ('created', models.DateField(auto_now_add=True, null=True)), - ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('name', models.CharField(max_length=100, unique=True)), - ('slug', models.SlugField(max_length=100, unique=True)), - ('color', utilities.fields.ColorField(default='9e9e9e', max_length=6)), - ('description', models.CharField(blank=True, max_length=200)), - ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), - ], - options={ - 'ordering': ('name',), - }, - ), - migrations.AddField( - model_name='inventoryitem', - name='role', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_items', to='dcim.inventoryitemrole'), - ), - ] diff --git a/netbox/dcim/migrations/0148_inventoryitem_component.py b/netbox/dcim/migrations/0148_inventoryitem_component.py deleted file mode 100644 index a18f41d3d..000000000 --- a/netbox/dcim/migrations/0148_inventoryitem_component.py +++ /dev/null @@ -1,23 +0,0 @@ -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), - ('dcim', '0147_inventoryitemrole'), - ] - - operations = [ - migrations.AddField( - model_name='inventoryitem', - name='component_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - migrations.AddField( - model_name='inventoryitem', - name='component_type', - field=models.ForeignKey(blank=True, limit_choices_to=models.Q(('app_label', 'dcim'), ('model__in', ('consoleport', 'consoleserverport', 'frontport', 'interface', 'poweroutlet', 'powerport', 'rearport'))), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype'), - ), - ] diff --git a/netbox/dcim/migrations/0149_inventoryitem_templates.py b/netbox/dcim/migrations/0149_inventoryitem_templates.py deleted file mode 100644 index f0b1f3cff..000000000 --- a/netbox/dcim/migrations/0149_inventoryitem_templates.py +++ /dev/null @@ -1,43 +0,0 @@ -from django.db import migrations, models -import django.db.models.deletion -import mptt.fields -import utilities.fields -import utilities.ordering - - -class Migration(migrations.Migration): - - dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), - ('dcim', '0148_inventoryitem_component'), - ] - - operations = [ - migrations.CreateModel( - name='InventoryItemTemplate', - fields=[ - ('created', models.DateField(auto_now_add=True, null=True)), - ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('name', models.CharField(max_length=64)), - ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), - ('label', models.CharField(blank=True, max_length=64)), - ('description', models.CharField(blank=True, max_length=200)), - ('component_id', models.PositiveBigIntegerField(blank=True, null=True)), - ('part_id', models.CharField(blank=True, max_length=50)), - ('lft', models.PositiveIntegerField(editable=False)), - ('rght', models.PositiveIntegerField(editable=False)), - ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), - ('level', models.PositiveIntegerField(editable=False)), - ('component_type', models.ForeignKey(blank=True, limit_choices_to=models.Q(('app_label', 'dcim'), ('model__in', ('consoleporttemplate', 'consoleserverporttemplate', 'frontporttemplate', 'interfacetemplate', 'poweroutlettemplate', 'powerporttemplate', 'rearporttemplate'))), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), - ('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype')), - ('manufacturer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_item_templates', to='dcim.manufacturer')), - ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child_items', to='dcim.inventoryitemtemplate')), - ('role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_item_templates', to='dcim.inventoryitemrole')), - ], - options={ - 'ordering': ('device_type__id', 'parent__id', '_name'), - 'unique_together': {('device_type', 'parent', 'name')}, - }, - ), - ] diff --git a/netbox/dcim/migrations/0150_interface_vrf.py b/netbox/dcim/migrations/0150_interface_vrf.py deleted file mode 100644 index f8741e4a0..000000000 --- a/netbox/dcim/migrations/0150_interface_vrf.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 3.2.11 on 2022-01-07 18:34 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('ipam', '0054_vlangroup_min_max_vids'), - ('dcim', '0149_inventoryitem_templates'), - ] - - operations = [ - migrations.AddField( - model_name='interface', - name='vrf', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='interfaces', to='ipam.vrf'), - ), - ] diff --git a/netbox/dcim/migrations/0151_interface_speed_duplex.py b/netbox/dcim/migrations/0151_interface_speed_duplex.py deleted file mode 100644 index 7e800f42a..000000000 --- a/netbox/dcim/migrations/0151_interface_speed_duplex.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.2.10 on 2022-01-08 18:23 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0150_interface_vrf'), - ] - - operations = [ - migrations.AddField( - model_name='interface', - name='duplex', - field=models.CharField(blank=True, max_length=50, null=True), - ), - migrations.AddField( - model_name='interface', - name='speed', - field=models.PositiveIntegerField(blank=True, null=True), - ), - ] diff --git a/netbox/dcim/migrations/0152_standardize_id_fields.py b/netbox/dcim/migrations/0152_standardize_id_fields.py deleted file mode 100644 index 6bf5b43f4..000000000 --- a/netbox/dcim/migrations/0152_standardize_id_fields.py +++ /dev/null @@ -1,274 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0151_interface_speed_duplex'), - ] - - operations = [ - # Model IDs - migrations.AlterField( - model_name='cable', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='cablepath', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='consoleport', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='consoleporttemplate', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='consoleserverport', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='consoleserverporttemplate', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='device', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='devicebay', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='devicebaytemplate', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='devicerole', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='devicetype', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='frontport', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='frontporttemplate', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='interface', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='interfacetemplate', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='inventoryitem', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='inventoryitemrole', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='inventoryitemtemplate', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='location', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='manufacturer', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='module', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='modulebay', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='modulebaytemplate', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='moduletype', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='platform', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='powerfeed', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='poweroutlet', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='poweroutlettemplate', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='powerpanel', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='powerport', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='powerporttemplate', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='rack', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='rackreservation', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='rackrole', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='rearport', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='rearporttemplate', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='region', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='site', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='sitegroup', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='virtualchassis', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), - ), - - # GFK IDs - migrations.AlterField( - model_name='cable', - name='termination_a_id', - field=models.PositiveBigIntegerField(), - ), - migrations.AlterField( - model_name='cable', - name='termination_b_id', - field=models.PositiveBigIntegerField(), - ), - migrations.AlterField( - model_name='cablepath', - name='destination_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='cablepath', - name='origin_id', - field=models.PositiveBigIntegerField(), - ), - migrations.AlterField( - model_name='consoleport', - name='_link_peer_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='consoleserverport', - name='_link_peer_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='frontport', - name='_link_peer_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='interface', - name='_link_peer_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='powerfeed', - name='_link_peer_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='poweroutlet', - name='_link_peer_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='powerport', - name='_link_peer_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='rearport', - name='_link_peer_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - ] diff --git a/netbox/dcim/migrations/0153_created_datetimefield.py b/netbox/dcim/migrations/0153_created_datetimefield.py deleted file mode 100644 index c1cc4132e..000000000 --- a/netbox/dcim/migrations/0153_created_datetimefield.py +++ /dev/null @@ -1,208 +0,0 @@ -# Generated by Django 4.0.2 on 2022-02-08 18:54 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0152_standardize_id_fields'), - ] - - operations = [ - migrations.AlterField( - model_name='cable', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='consoleport', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='consoleporttemplate', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='consoleserverport', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='consoleserverporttemplate', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='device', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='devicebay', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='devicebaytemplate', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='devicerole', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='devicetype', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='frontport', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='frontporttemplate', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='interface', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='interfacetemplate', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='inventoryitem', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='inventoryitemrole', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='inventoryitemtemplate', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='location', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='manufacturer', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='module', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='modulebay', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='modulebaytemplate', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='moduletype', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='platform', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='powerfeed', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='poweroutlet', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='poweroutlettemplate', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='powerpanel', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='powerport', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='powerporttemplate', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='rack', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='rackreservation', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='rackrole', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='rearport', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='rearporttemplate', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='region', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='site', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='sitegroup', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AlterField( - model_name='virtualchassis', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - ] diff --git a/netbox/dcim/migrations/0154_half_height_rack_units.py b/netbox/dcim/migrations/0154_half_height_rack_units.py deleted file mode 100644 index f212aa21a..000000000 --- a/netbox/dcim/migrations/0154_half_height_rack_units.py +++ /dev/null @@ -1,23 +0,0 @@ -import django.contrib.postgres.fields -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0153_created_datetimefield'), - ] - - operations = [ - migrations.AlterField( - model_name='devicetype', - name='u_height', - field=models.DecimalField(decimal_places=1, default=1.0, max_digits=4), - ), - migrations.AlterField( - model_name='device', - name='position', - field=models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100.5)]), - ), - ] diff --git a/netbox/dcim/migrations/0155_interface_poe_mode_type.py b/netbox/dcim/migrations/0155_interface_poe_mode_type.py deleted file mode 100644 index 13f2ddfc0..000000000 --- a/netbox/dcim/migrations/0155_interface_poe_mode_type.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 4.0.5 on 2022-06-22 00:36 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0154_half_height_rack_units'), - ] - - operations = [ - migrations.AddField( - model_name='interface', - name='poe_mode', - field=models.CharField(blank=True, max_length=50), - ), - migrations.AddField( - model_name='interface', - name='poe_type', - field=models.CharField(blank=True, max_length=50), - ), - migrations.AddField( - model_name='interfacetemplate', - name='poe_mode', - field=models.CharField(blank=True, max_length=50), - ), - migrations.AddField( - model_name='interfacetemplate', - name='poe_type', - field=models.CharField(blank=True, max_length=50), - ), - ] diff --git a/netbox/dcim/migrations/0157_new_cabling_models.py b/netbox/dcim/migrations/0157_new_cabling_models.py deleted file mode 100644 index a3a650086..000000000 --- a/netbox/dcim/migrations/0157_new_cabling_models.py +++ /dev/null @@ -1,95 +0,0 @@ -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), - ('dcim', '0156_location_status'), - ] - - operations = [ - - # Create CableTermination model - migrations.CreateModel( - name='CableTermination', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), - ('cable_end', models.CharField(max_length=1)), - ('termination_id', models.PositiveBigIntegerField()), - ('cable', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='dcim.cable')), - ('termination_type', models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'circuits'), ('model__in', ('circuittermination',))), models.Q(('app_label', 'dcim'), ('model__in', ('consoleport', 'consoleserverport', 'frontport', 'interface', 'powerfeed', 'poweroutlet', 'powerport', 'rearport'))), _connector='OR')), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), - ('_device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.device')), - ('_rack', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.rack')), - ('_location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.location')), - ('_site', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.site')), - ], - options={ - 'ordering': ('cable', 'cable_end', 'pk'), - }, - ), - migrations.AddConstraint( - model_name='cabletermination', - constraint=models.UniqueConstraint(fields=('termination_type', 'termination_id'), name='dcim_cable_termination_unique_termination'), - ), - - # Update CablePath model - migrations.RenameField( - model_name='cablepath', - old_name='path', - new_name='_nodes', - ), - migrations.AddField( - model_name='cablepath', - name='path', - field=models.JSONField(default=list), - ), - migrations.AddField( - model_name='cablepath', - name='is_complete', - field=models.BooleanField(default=False), - ), - - # Add cable_end field to cable termination models - migrations.AddField( - model_name='consoleport', - name='cable_end', - field=models.CharField(blank=True, max_length=1), - ), - migrations.AddField( - model_name='consoleserverport', - name='cable_end', - field=models.CharField(blank=True, max_length=1), - ), - migrations.AddField( - model_name='frontport', - name='cable_end', - field=models.CharField(blank=True, max_length=1), - ), - migrations.AddField( - model_name='interface', - name='cable_end', - field=models.CharField(blank=True, max_length=1), - ), - migrations.AddField( - model_name='powerfeed', - name='cable_end', - field=models.CharField(blank=True, max_length=1), - ), - migrations.AddField( - model_name='poweroutlet', - name='cable_end', - field=models.CharField(blank=True, max_length=1), - ), - migrations.AddField( - model_name='powerport', - name='cable_end', - field=models.CharField(blank=True, max_length=1), - ), - migrations.AddField( - model_name='rearport', - name='cable_end', - field=models.CharField(blank=True, max_length=1), - ), - ] diff --git a/netbox/dcim/migrations/0158_populate_cable_terminations.py b/netbox/dcim/migrations/0158_populate_cable_terminations.py deleted file mode 100644 index 72d7f154a..000000000 --- a/netbox/dcim/migrations/0158_populate_cable_terminations.py +++ /dev/null @@ -1,87 +0,0 @@ -import sys - -from django.db import migrations - - -def cache_related_objects(termination): - """ - Replicate caching logic from CableTermination.cache_related_objects() - """ - attrs = {} - - # Device components - if getattr(termination, 'device', None): - attrs['_device'] = termination.device - attrs['_rack'] = termination.device.rack - attrs['_location'] = termination.device.location - attrs['_site'] = termination.device.site - - # Power feeds - elif getattr(termination, 'rack', None): - attrs['_rack'] = termination.rack - attrs['_location'] = termination.rack.location - attrs['_site'] = termination.rack.site - - # Circuit terminations - elif getattr(termination, 'site', None): - attrs['_site'] = termination.site - - return attrs - - -def populate_cable_terminations(apps, schema_editor): - """ - Replicate terminations from the Cable model into CableTermination instances. - """ - ContentType = apps.get_model('contenttypes', 'ContentType') - Cable = apps.get_model('dcim', 'Cable') - CableTermination = apps.get_model('dcim', 'CableTermination') - - # Retrieve the necessary data from Cable objects - cables = Cable.objects.values( - 'id', 'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id' - ) - - # Queue CableTerminations to be created - cable_terminations = [] - cable_count = cables.count() - for i, cable in enumerate(cables, start=1): - for cable_end in ('a', 'b'): - # We must manually instantiate the termination object, because GFK fields are not - # supported within migrations. - termination_ct = ContentType.objects.get(pk=cable[f'termination_{cable_end}_type']) - termination_model = apps.get_model(termination_ct.app_label, termination_ct.model) - termination = termination_model.objects.get(pk=cable[f'termination_{cable_end}_id']) - - cable_terminations.append(CableTermination( - cable_id=cable['id'], - cable_end=cable_end.upper(), - termination_type_id=cable[f'termination_{cable_end}_type'], - termination_id=cable[f'termination_{cable_end}_id'], - **cache_related_objects(termination) - )) - - # Output progress occasionally - if 'test' not in sys.argv and not i % 100: - progress = float(i) * 100 / cable_count - if i == 100: - print('') - sys.stdout.write(f"\r Updated {i}/{cable_count} cables ({progress:.2f}%)") - sys.stdout.flush() - - # Bulk create the termination objects - CableTermination.objects.bulk_create(cable_terminations, batch_size=100) - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0157_new_cabling_models'), - ] - - operations = [ - migrations.RunPython( - code=populate_cable_terminations, - reverse_code=migrations.RunPython.noop - ), - ] diff --git a/netbox/dcim/migrations/0159_populate_cable_paths.py b/netbox/dcim/migrations/0159_populate_cable_paths.py deleted file mode 100644 index 22fe4b67e..000000000 --- a/netbox/dcim/migrations/0159_populate_cable_paths.py +++ /dev/null @@ -1,50 +0,0 @@ -from django.db import migrations - -from dcim.utils import compile_path_node - - -def populate_cable_paths(apps, schema_editor): - """ - Replicate terminations from the Cable model into CableTermination instances. - """ - CablePath = apps.get_model('dcim', 'CablePath') - - # Construct the new two-dimensional path, and add the origin & destination objects to the nodes list - cable_paths = [] - for cablepath in CablePath.objects.all(): - - # Origin - origin = compile_path_node(cablepath.origin_type_id, cablepath.origin_id) - cablepath.path.append([origin]) - cablepath._nodes.insert(0, origin) - - # Transit nodes - cablepath.path.extend([ - [node] for node in cablepath._nodes[1:] - ]) - - # Destination - if cablepath.destination_id: - destination = compile_path_node(cablepath.destination_type_id, cablepath.destination_id) - cablepath.path.append([destination]) - cablepath._nodes.append(destination) - cablepath.is_complete = True - - cable_paths.append(cablepath) - - # Bulk update all CableTerminations - CablePath.objects.bulk_update(cable_paths, fields=('path', '_nodes', 'is_complete'), batch_size=100) - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0158_populate_cable_terminations'), - ] - - operations = [ - migrations.RunPython( - code=populate_cable_paths, - reverse_code=migrations.RunPython.noop - ), - ] diff --git a/netbox/dcim/migrations/0160_populate_cable_ends.py b/netbox/dcim/migrations/0160_populate_cable_ends.py deleted file mode 100644 index 53e042abc..000000000 --- a/netbox/dcim/migrations/0160_populate_cable_ends.py +++ /dev/null @@ -1,46 +0,0 @@ -from django.db import migrations - - -def populate_cable_terminations(apps, schema_editor): - Cable = apps.get_model('dcim', 'Cable') - - cable_termination_models = ( - apps.get_model('dcim', 'ConsolePort'), - apps.get_model('dcim', 'ConsoleServerPort'), - apps.get_model('dcim', 'PowerPort'), - apps.get_model('dcim', 'PowerOutlet'), - apps.get_model('dcim', 'Interface'), - apps.get_model('dcim', 'FrontPort'), - apps.get_model('dcim', 'RearPort'), - apps.get_model('dcim', 'PowerFeed'), - apps.get_model('circuits', 'CircuitTermination'), - ) - - for model in cable_termination_models: - model.objects.filter( - id__in=Cable.objects.filter( - termination_a_type__app_label=model._meta.app_label, - termination_a_type__model=model._meta.model_name - ).values_list('termination_a_id', flat=True) - ).update(cable_end='A') - model.objects.filter( - id__in=Cable.objects.filter( - termination_b_type__app_label=model._meta.app_label, - termination_b_type__model=model._meta.model_name - ).values_list('termination_b_id', flat=True) - ).update(cable_end='B') - - -class Migration(migrations.Migration): - - dependencies = [ - ('circuits', '0037_new_cabling_models'), - ('dcim', '0159_populate_cable_paths'), - ] - - operations = [ - migrations.RunPython( - code=populate_cable_terminations, - reverse_code=migrations.RunPython.noop - ), - ] diff --git a/netbox/dcim/migrations/0162_unique_constraints.py b/netbox/dcim/migrations/0160_squashed_0166.py similarity index 58% rename from netbox/dcim/migrations/0162_unique_constraints.py rename to netbox/dcim/migrations/0160_squashed_0166.py index d52dbb6c9..440a8115e 100644 --- a/netbox/dcim/migrations/0162_unique_constraints.py +++ b/netbox/dcim/migrations/0160_squashed_0166.py @@ -1,14 +1,146 @@ -from django.db import migrations, models import django.db.models.functions.text +import taggit.managers +from django.db import migrations, models + +import utilities.json class Migration(migrations.Migration): - dependencies = [ + replaces = [ + ('dcim', '0160_populate_cable_ends'), ('dcim', '0161_cabling_cleanup'), + ('dcim', '0162_unique_constraints'), + ('dcim', '0163_weight_fields'), + ('dcim', '0164_rack_mounting_depth'), + ('dcim', '0165_standardize_description_comments'), + ('dcim', '0166_virtualdevicecontext') + ] + + dependencies = [ + ('ipam', '0047_squashed_0053'), + ('tenancy', '0009_standardize_description_comments'), + ('circuits', '0037_new_cabling_models'), + ('dcim', '0159_populate_cable_paths'), ] operations = [ + migrations.AlterModelOptions( + name='cable', + options={'ordering': ('pk',)}, + ), + migrations.AlterUniqueTogether( + name='cable', + unique_together=set(), + ), + migrations.RemoveField( + model_name='cable', + name='termination_a_id', + ), + migrations.RemoveField( + model_name='cable', + name='termination_a_type', + ), + migrations.RemoveField( + model_name='cable', + name='termination_b_id', + ), + migrations.RemoveField( + model_name='cable', + name='termination_b_type', + ), + migrations.RemoveField( + model_name='cable', + name='_termination_a_device', + ), + migrations.RemoveField( + model_name='cable', + name='_termination_b_device', + ), + migrations.AlterUniqueTogether( + name='cablepath', + unique_together=set(), + ), + migrations.RemoveField( + model_name='cablepath', + name='destination_id', + ), + migrations.RemoveField( + model_name='cablepath', + name='destination_type', + ), + migrations.RemoveField( + model_name='cablepath', + name='origin_id', + ), + migrations.RemoveField( + model_name='cablepath', + name='origin_type', + ), + migrations.RemoveField( + model_name='consoleport', + name='_link_peer_id', + ), + migrations.RemoveField( + model_name='consoleport', + name='_link_peer_type', + ), + migrations.RemoveField( + model_name='consoleserverport', + name='_link_peer_id', + ), + migrations.RemoveField( + model_name='consoleserverport', + name='_link_peer_type', + ), + migrations.RemoveField( + model_name='frontport', + name='_link_peer_id', + ), + migrations.RemoveField( + model_name='frontport', + name='_link_peer_type', + ), + migrations.RemoveField( + model_name='interface', + name='_link_peer_id', + ), + migrations.RemoveField( + model_name='interface', + name='_link_peer_type', + ), + migrations.RemoveField( + model_name='powerfeed', + name='_link_peer_id', + ), + migrations.RemoveField( + model_name='powerfeed', + name='_link_peer_type', + ), + migrations.RemoveField( + model_name='poweroutlet', + name='_link_peer_id', + ), + migrations.RemoveField( + model_name='poweroutlet', + name='_link_peer_type', + ), + migrations.RemoveField( + model_name='powerport', + name='_link_peer_id', + ), + migrations.RemoveField( + model_name='powerport', + name='_link_peer_type', + ), + migrations.RemoveField( + model_name='rearport', + name='_link_peer_id', + ), + migrations.RemoveField( + model_name='rearport', + name='_link_peer_type', + ), migrations.RemoveConstraint( model_name='cabletermination', name='dcim_cable_termination_unique_termination', @@ -329,4 +461,164 @@ class Migration(migrations.Migration): model_name='sitegroup', constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('slug',), name='dcim_sitegroup_slug', violation_error_message='A top-level site group with this slug already exists.'), ), + migrations.AddField( + model_name='devicetype', + name='weight', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True), + ), + migrations.AddField( + model_name='devicetype', + name='weight_unit', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='devicetype', + name='_abs_weight', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='moduletype', + name='weight', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True), + ), + migrations.AddField( + model_name='moduletype', + name='weight_unit', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='moduletype', + name='_abs_weight', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='rack', + name='weight', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True), + ), + migrations.AddField( + model_name='rack', + name='max_weight', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='rack', + name='weight_unit', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='rack', + name='_abs_weight', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='rack', + name='_abs_max_weight', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='rack', + name='mounting_depth', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='cable', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='cable', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='device', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='devicetype', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='module', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='moduletype', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='powerfeed', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='powerpanel', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='powerpanel', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='rack', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='rackreservation', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='virtualchassis', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='virtualchassis', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.CreateModel( + name='VirtualDeviceContext', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)), + ('description', models.CharField(blank=True, max_length=200)), + ('name', models.CharField(max_length=64)), + ('status', models.CharField(max_length=50)), + ('identifier', models.PositiveSmallIntegerField(blank=True, null=True)), + ('comments', models.TextField(blank=True)), + ('device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vdcs', to='dcim.device')), + ('primary_ip4', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress')), + ('primary_ip6', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vdcs', to='tenancy.tenant')), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.AddField( + model_name='interface', + name='vdcs', + field=models.ManyToManyField(related_name='interfaces', to='dcim.virtualdevicecontext'), + ), + migrations.AddConstraint( + model_name='virtualdevicecontext', + constraint=models.UniqueConstraint(fields=('device', 'identifier'), name='dcim_virtualdevicecontext_device_identifier'), + ), + migrations.AddConstraint( + model_name='virtualdevicecontext', + constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_virtualdevicecontext_device_name'), + ), ] diff --git a/netbox/dcim/migrations/0161_cabling_cleanup.py b/netbox/dcim/migrations/0161_cabling_cleanup.py deleted file mode 100644 index 8a1b7a09e..000000000 --- a/netbox/dcim/migrations/0161_cabling_cleanup.py +++ /dev/null @@ -1,134 +0,0 @@ -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0160_populate_cable_ends'), - ] - - operations = [ - - # Remove old fields from Cable - migrations.AlterModelOptions( - name='cable', - options={'ordering': ('pk',)}, - ), - migrations.AlterUniqueTogether( - name='cable', - unique_together=set(), - ), - migrations.RemoveField( - model_name='cable', - name='termination_a_id', - ), - migrations.RemoveField( - model_name='cable', - name='termination_a_type', - ), - migrations.RemoveField( - model_name='cable', - name='termination_b_id', - ), - migrations.RemoveField( - model_name='cable', - name='termination_b_type', - ), - migrations.RemoveField( - model_name='cable', - name='_termination_a_device', - ), - migrations.RemoveField( - model_name='cable', - name='_termination_b_device', - ), - - # Remove old fields from CablePath - migrations.AlterUniqueTogether( - name='cablepath', - unique_together=set(), - ), - migrations.RemoveField( - model_name='cablepath', - name='destination_id', - ), - migrations.RemoveField( - model_name='cablepath', - name='destination_type', - ), - migrations.RemoveField( - model_name='cablepath', - name='origin_id', - ), - migrations.RemoveField( - model_name='cablepath', - name='origin_type', - ), - - # Remove link peer type/ID fields from cable termination models - migrations.RemoveField( - model_name='consoleport', - name='_link_peer_id', - ), - migrations.RemoveField( - model_name='consoleport', - name='_link_peer_type', - ), - migrations.RemoveField( - model_name='consoleserverport', - name='_link_peer_id', - ), - migrations.RemoveField( - model_name='consoleserverport', - name='_link_peer_type', - ), - migrations.RemoveField( - model_name='frontport', - name='_link_peer_id', - ), - migrations.RemoveField( - model_name='frontport', - name='_link_peer_type', - ), - migrations.RemoveField( - model_name='interface', - name='_link_peer_id', - ), - migrations.RemoveField( - model_name='interface', - name='_link_peer_type', - ), - migrations.RemoveField( - model_name='powerfeed', - name='_link_peer_id', - ), - migrations.RemoveField( - model_name='powerfeed', - name='_link_peer_type', - ), - migrations.RemoveField( - model_name='poweroutlet', - name='_link_peer_id', - ), - migrations.RemoveField( - model_name='poweroutlet', - name='_link_peer_type', - ), - migrations.RemoveField( - model_name='powerport', - name='_link_peer_id', - ), - migrations.RemoveField( - model_name='powerport', - name='_link_peer_type', - ), - migrations.RemoveField( - model_name='rearport', - name='_link_peer_id', - ), - migrations.RemoveField( - model_name='rearport', - name='_link_peer_type', - ), - - ] diff --git a/netbox/dcim/migrations/0163_weight_fields.py b/netbox/dcim/migrations/0163_weight_fields.py deleted file mode 100644 index ddcc01164..000000000 --- a/netbox/dcim/migrations/0163_weight_fields.py +++ /dev/null @@ -1,72 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0162_unique_constraints'), - ] - - operations = [ - - # Device types - migrations.AddField( - model_name='devicetype', - name='weight', - field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True), - ), - migrations.AddField( - model_name='devicetype', - name='weight_unit', - field=models.CharField(blank=True, max_length=50), - ), - migrations.AddField( - model_name='devicetype', - name='_abs_weight', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - - # Module types - migrations.AddField( - model_name='moduletype', - name='weight', - field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True), - ), - migrations.AddField( - model_name='moduletype', - name='weight_unit', - field=models.CharField(blank=True, max_length=50), - ), - migrations.AddField( - model_name='moduletype', - name='_abs_weight', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - - # Racks - migrations.AddField( - model_name='rack', - name='weight', - field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True), - ), - migrations.AddField( - model_name='rack', - name='max_weight', - field=models.PositiveIntegerField(blank=True, null=True), - ), - migrations.AddField( - model_name='rack', - name='weight_unit', - field=models.CharField(blank=True, max_length=50), - ), - migrations.AddField( - model_name='rack', - name='_abs_weight', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - migrations.AddField( - model_name='rack', - name='_abs_max_weight', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - ] diff --git a/netbox/dcim/migrations/0164_rack_mounting_depth.py b/netbox/dcim/migrations/0164_rack_mounting_depth.py deleted file mode 100644 index 96836e0d6..000000000 --- a/netbox/dcim/migrations/0164_rack_mounting_depth.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.1.1 on 2022-10-27 14:01 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0163_weight_fields'), - ] - - operations = [ - migrations.AddField( - model_name='rack', - name='mounting_depth', - field=models.PositiveSmallIntegerField(blank=True, null=True), - ), - ] diff --git a/netbox/dcim/migrations/0165_standardize_description_comments.py b/netbox/dcim/migrations/0165_standardize_description_comments.py deleted file mode 100644 index f17f1d321..000000000 --- a/netbox/dcim/migrations/0165_standardize_description_comments.py +++ /dev/null @@ -1,78 +0,0 @@ -# Generated by Django 4.1.2 on 2022-11-03 18:24 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0164_rack_mounting_depth'), - ] - - operations = [ - migrations.AddField( - model_name='cable', - name='comments', - field=models.TextField(blank=True), - ), - migrations.AddField( - model_name='cable', - name='description', - field=models.CharField(blank=True, max_length=200), - ), - migrations.AddField( - model_name='device', - name='description', - field=models.CharField(blank=True, max_length=200), - ), - migrations.AddField( - model_name='devicetype', - name='description', - field=models.CharField(blank=True, max_length=200), - ), - migrations.AddField( - model_name='module', - name='description', - field=models.CharField(blank=True, max_length=200), - ), - migrations.AddField( - model_name='moduletype', - name='description', - field=models.CharField(blank=True, max_length=200), - ), - migrations.AddField( - model_name='powerfeed', - name='description', - field=models.CharField(blank=True, max_length=200), - ), - migrations.AddField( - model_name='powerpanel', - name='comments', - field=models.TextField(blank=True), - ), - migrations.AddField( - model_name='powerpanel', - name='description', - field=models.CharField(blank=True, max_length=200), - ), - migrations.AddField( - model_name='rack', - name='description', - field=models.CharField(blank=True, max_length=200), - ), - migrations.AddField( - model_name='rackreservation', - name='comments', - field=models.TextField(blank=True), - ), - migrations.AddField( - model_name='virtualchassis', - name='comments', - field=models.TextField(blank=True), - ), - migrations.AddField( - model_name='virtualchassis', - name='description', - field=models.CharField(blank=True, max_length=200), - ), - ] diff --git a/netbox/dcim/migrations/0166_virtualdevicecontext.py b/netbox/dcim/migrations/0166_virtualdevicecontext.py deleted file mode 100644 index 05becbdc6..000000000 --- a/netbox/dcim/migrations/0166_virtualdevicecontext.py +++ /dev/null @@ -1,54 +0,0 @@ -# Generated by Django 4.1.2 on 2022-11-10 16:56 - -from django.db import migrations, models -import django.db.models.deletion -import taggit.managers -import utilities.json - - -class Migration(migrations.Migration): - - dependencies = [ - ('ipam', '0063_standardize_description_comments'), - ('extras', '0082_savedfilter'), - ('tenancy', '0009_standardize_description_comments'), - ('dcim', '0165_standardize_description_comments'), - ] - - operations = [ - migrations.CreateModel( - name='VirtualDeviceContext', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), - ('created', models.DateTimeField(auto_now_add=True, null=True)), - ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)), - ('description', models.CharField(blank=True, max_length=200)), - ('name', models.CharField(max_length=64)), - ('status', models.CharField(max_length=50)), - ('identifier', models.PositiveSmallIntegerField(blank=True, null=True)), - ('comments', models.TextField(blank=True)), - ('device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vdcs', to='dcim.device')), - ('primary_ip4', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress')), - ('primary_ip6', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress')), - ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), - ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vdcs', to='tenancy.tenant')), - ], - options={ - 'ordering': ['name'], - }, - ), - migrations.AddField( - model_name='interface', - name='vdcs', - field=models.ManyToManyField(related_name='interfaces', to='dcim.virtualdevicecontext'), - ), - migrations.AddConstraint( - model_name='virtualdevicecontext', - constraint=models.UniqueConstraint(fields=('device', 'identifier'), name='dcim_virtualdevicecontext_device_identifier'), - ), - migrations.AddConstraint( - model_name='virtualdevicecontext', - constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_virtualdevicecontext_device_name'), - ), - ] diff --git a/netbox/dcim/migrations/0167_module_status.py b/netbox/dcim/migrations/0167_module_status.py deleted file mode 100644 index c048b4bd8..000000000 --- a/netbox/dcim/migrations/0167_module_status.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.1.2 on 2022-12-09 15:09 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0166_virtualdevicecontext'), - ] - - operations = [ - migrations.AddField( - model_name='module', - name='status', - field=models.CharField(default='active', max_length=50), - ), - ] diff --git a/netbox/dcim/migrations/0167_squashed_0182.py b/netbox/dcim/migrations/0167_squashed_0182.py new file mode 100644 index 000000000..735cb3efa --- /dev/null +++ b/netbox/dcim/migrations/0167_squashed_0182.py @@ -0,0 +1,251 @@ +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + +import utilities.fields + + +class Migration(migrations.Migration): + + replaces = [ + ('dcim', '0167_module_status'), + ('dcim', '0168_interface_template_enabled'), + ('dcim', '0169_devicetype_default_platform'), + ('dcim', '0170_configtemplate'), + ('dcim', '0171_cabletermination_change_logging'), + ('dcim', '0172_larger_power_draw_values'), + ('dcim', '0173_remove_napalm_fields'), + ('dcim', '0174_device_latitude_device_longitude'), + ('dcim', '0174_rack_starting_unit'), + ('dcim', '0175_device_oob_ip'), + ('dcim', '0176_device_component_counters'), + ('dcim', '0177_devicetype_component_counters'), + ('dcim', '0178_virtual_chassis_member_counter'), + ('dcim', '0179_interfacetemplate_rf_role'), + ('dcim', '0180_powerfeed_tenant'), + ('dcim', '0181_rename_device_role_device_role'), + ('dcim', '0182_zero_length_cable_fix') + ] + + dependencies = [ + ('extras', '0086_configtemplate'), + ('tenancy', '0010_tenant_relax_uniqueness'), + ('ipam', '0047_squashed_0053'), + ('dcim', '0166_virtualdevicecontext'), + ] + + operations = [ + migrations.AddField( + model_name='module', + name='status', + field=models.CharField(default='active', max_length=50), + ), + migrations.AddField( + model_name='interfacetemplate', + name='enabled', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='interfacetemplate', + name='bridge', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bridge_interfaces', to='dcim.interfacetemplate'), + ), + migrations.AddField( + model_name='devicetype', + name='default_platform', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.platform'), + ), + migrations.AddField( + model_name='device', + name='config_template', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='extras.configtemplate'), + ), + migrations.AddField( + model_name='devicerole', + name='config_template', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='device_roles', to='extras.configtemplate'), + ), + migrations.AddField( + model_name='platform', + name='config_template', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='platforms', to='extras.configtemplate'), + ), + migrations.AddField( + model_name='cabletermination', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='cabletermination', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='powerport', + name='allocated_draw', + field=models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]), + ), + migrations.AlterField( + model_name='powerport', + name='maximum_draw', + field=models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]), + ), + migrations.AlterField( + model_name='powerporttemplate', + name='allocated_draw', + field=models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]), + ), + migrations.AlterField( + model_name='powerporttemplate', + name='maximum_draw', + field=models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]), + ), + migrations.RemoveField( + model_name='platform', + name='napalm_args', + ), + migrations.RemoveField( + model_name='platform', + name='napalm_driver', + ), + migrations.AddField( + model_name='device', + name='latitude', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=8, null=True), + ), + migrations.AddField( + model_name='device', + name='longitude', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True), + ), + migrations.AddField( + model_name='rack', + name='starting_unit', + field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1)]), + ), + migrations.AddField( + model_name='device', + name='oob_ip', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress'), + ), + migrations.AddField( + model_name='device', + name='console_port_count', + field=utilities.fields.CounterCacheField(default=0, editable=False, to_field='device', to_model='dcim.ConsolePort'), + ), + migrations.AddField( + model_name='device', + name='console_server_port_count', + field=utilities.fields.CounterCacheField(default=0, editable=False, to_field='device', to_model='dcim.ConsoleServerPort'), + ), + migrations.AddField( + model_name='device', + name='power_port_count', + field=utilities.fields.CounterCacheField(default=0, editable=False, to_field='device', to_model='dcim.PowerPort'), + ), + migrations.AddField( + model_name='device', + name='power_outlet_count', + field=utilities.fields.CounterCacheField(default=0, editable=False, to_field='device', to_model='dcim.PowerOutlet'), + ), + migrations.AddField( + model_name='device', + name='interface_count', + field=utilities.fields.CounterCacheField(default=0, editable=False, to_field='device', to_model='dcim.Interface'), + ), + migrations.AddField( + model_name='device', + name='front_port_count', + field=utilities.fields.CounterCacheField(default=0, editable=False, to_field='device', to_model='dcim.FrontPort'), + ), + migrations.AddField( + model_name='device', + name='rear_port_count', + field=utilities.fields.CounterCacheField(default=0, editable=False, to_field='device', to_model='dcim.RearPort'), + ), + migrations.AddField( + model_name='device', + name='device_bay_count', + field=utilities.fields.CounterCacheField(default=0, editable=False, to_field='device', to_model='dcim.DeviceBay'), + ), + migrations.AddField( + model_name='device', + name='module_bay_count', + field=utilities.fields.CounterCacheField(default=0, editable=False, to_field='device', to_model='dcim.ModuleBay'), + ), + migrations.AddField( + model_name='device', + name='inventory_item_count', + field=utilities.fields.CounterCacheField(default=0, editable=False, to_field='device', to_model='dcim.InventoryItem'), + ), + migrations.AddField( + model_name='devicetype', + name='console_port_template_count', + field=utilities.fields.CounterCacheField(default=0, editable=False, to_field='device_type', to_model='dcim.ConsolePortTemplate'), + ), + migrations.AddField( + model_name='devicetype', + name='console_server_port_template_count', + field=utilities.fields.CounterCacheField(default=0, editable=False, to_field='device_type', to_model='dcim.ConsoleServerPortTemplate'), + ), + migrations.AddField( + model_name='devicetype', + name='power_port_template_count', + field=utilities.fields.CounterCacheField(default=0, editable=False, to_field='device_type', to_model='dcim.PowerPortTemplate'), + ), + migrations.AddField( + model_name='devicetype', + name='power_outlet_template_count', + field=utilities.fields.CounterCacheField(default=0, editable=False, to_field='device_type', to_model='dcim.PowerOutletTemplate'), + ), + migrations.AddField( + model_name='devicetype', + name='interface_template_count', + field=utilities.fields.CounterCacheField(default=0, editable=False, to_field='device_type', to_model='dcim.InterfaceTemplate'), + ), + migrations.AddField( + model_name='devicetype', + name='front_port_template_count', + field=utilities.fields.CounterCacheField(default=0, editable=False, to_field='device_type', to_model='dcim.FrontPortTemplate'), + ), + migrations.AddField( + model_name='devicetype', + name='rear_port_template_count', + field=utilities.fields.CounterCacheField(default=0, editable=False, to_field='device_type', to_model='dcim.RearPortTemplate'), + ), + migrations.AddField( + model_name='devicetype', + name='device_bay_template_count', + field=utilities.fields.CounterCacheField(default=0, editable=False, to_field='device_type', to_model='dcim.DeviceBayTemplate'), + ), + migrations.AddField( + model_name='devicetype', + name='module_bay_template_count', + field=utilities.fields.CounterCacheField(default=0, editable=False, to_field='device_type', to_model='dcim.ModuleBayTemplate'), + ), + migrations.AddField( + model_name='devicetype', + name='inventory_item_template_count', + field=utilities.fields.CounterCacheField(default=0, editable=False, to_field='device_type', to_model='dcim.InventoryItemTemplate'), + ), + migrations.AddField( + model_name='virtualchassis', + name='member_count', + field=utilities.fields.CounterCacheField(default=0, editable=False, to_field='virtual_chassis', to_model='dcim.Device'), + ), + migrations.AddField( + model_name='interfacetemplate', + name='rf_role', + field=models.CharField(blank=True, max_length=30), + ), + migrations.AddField( + model_name='powerfeed', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='power_feeds', to='tenancy.tenant'), + ), + migrations.RenameField( + model_name='device', + old_name='device_role', + new_name='role', + ), + ] diff --git a/netbox/dcim/migrations/0168_interface_template_enabled.py b/netbox/dcim/migrations/0168_interface_template_enabled.py deleted file mode 100644 index af18be51d..000000000 --- a/netbox/dcim/migrations/0168_interface_template_enabled.py +++ /dev/null @@ -1,22 +0,0 @@ -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0167_module_status'), - ] - - operations = [ - migrations.AddField( - model_name='interfacetemplate', - name='enabled', - field=models.BooleanField(default=True), - ), - migrations.AddField( - model_name='interfacetemplate', - name='bridge', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bridge_interfaces', to='dcim.interfacetemplate'), - ), - ] diff --git a/netbox/dcim/migrations/0169_devicetype_default_platform.py b/netbox/dcim/migrations/0169_devicetype_default_platform.py deleted file mode 100644 index a143f2c62..000000000 --- a/netbox/dcim/migrations/0169_devicetype_default_platform.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 4.1.6 on 2023-02-10 18:06 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0168_interface_template_enabled'), - ] - - operations = [ - migrations.AddField( - model_name='devicetype', - name='default_platform', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.platform'), - ), - ] diff --git a/netbox/dcim/migrations/0170_configtemplate.py b/netbox/dcim/migrations/0170_configtemplate.py deleted file mode 100644 index f9508424d..000000000 --- a/netbox/dcim/migrations/0170_configtemplate.py +++ /dev/null @@ -1,28 +0,0 @@ -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('extras', '0086_configtemplate'), - ('dcim', '0169_devicetype_default_platform'), - ] - - operations = [ - migrations.AddField( - model_name='device', - name='config_template', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='extras.configtemplate'), - ), - migrations.AddField( - model_name='devicerole', - name='config_template', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='device_roles', to='extras.configtemplate'), - ), - migrations.AddField( - model_name='platform', - name='config_template', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='platforms', to='extras.configtemplate'), - ), - ] diff --git a/netbox/dcim/migrations/0171_cabletermination_change_logging.py b/netbox/dcim/migrations/0171_cabletermination_change_logging.py deleted file mode 100644 index e2131e45d..000000000 --- a/netbox/dcim/migrations/0171_cabletermination_change_logging.py +++ /dev/null @@ -1,21 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0170_configtemplate'), - ] - - operations = [ - migrations.AddField( - model_name='cabletermination', - name='created', - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AddField( - model_name='cabletermination', - name='last_updated', - field=models.DateTimeField(auto_now=True, null=True), - ), - ] diff --git a/netbox/dcim/migrations/0172_larger_power_draw_values.py b/netbox/dcim/migrations/0172_larger_power_draw_values.py deleted file mode 100644 index 729daf836..000000000 --- a/netbox/dcim/migrations/0172_larger_power_draw_values.py +++ /dev/null @@ -1,42 +0,0 @@ -# Generated by Django 4.1.9 on 2023-05-12 18:46 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0171_cabletermination_change_logging'), - ] - - operations = [ - migrations.AlterField( - model_name='powerport', - name='allocated_draw', - field=models.PositiveIntegerField( - blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)] - ), - ), - migrations.AlterField( - model_name='powerport', - name='maximum_draw', - field=models.PositiveIntegerField( - blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)] - ), - ), - migrations.AlterField( - model_name='powerporttemplate', - name='allocated_draw', - field=models.PositiveIntegerField( - blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)] - ), - ), - migrations.AlterField( - model_name='powerporttemplate', - name='maximum_draw', - field=models.PositiveIntegerField( - blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)] - ), - ), - ] diff --git a/netbox/dcim/migrations/0173_remove_napalm_fields.py b/netbox/dcim/migrations/0173_remove_napalm_fields.py deleted file mode 100644 index 61c7c5695..000000000 --- a/netbox/dcim/migrations/0173_remove_napalm_fields.py +++ /dev/null @@ -1,19 +0,0 @@ -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0172_larger_power_draw_values'), - ] - - operations = [ - migrations.RemoveField( - model_name='platform', - name='napalm_args', - ), - migrations.RemoveField( - model_name='platform', - name='napalm_driver', - ), - ] diff --git a/netbox/dcim/migrations/0174_device_latitude_device_longitude.py b/netbox/dcim/migrations/0174_device_latitude_device_longitude.py deleted file mode 100644 index f9f72f9f8..000000000 --- a/netbox/dcim/migrations/0174_device_latitude_device_longitude.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.1.9 on 2023-05-31 22:13 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ('dcim', '0173_remove_napalm_fields'), - ] - - operations = [ - migrations.AddField( - model_name='device', - name='latitude', - field=models.DecimalField(blank=True, decimal_places=6, max_digits=8, null=True), - ), - migrations.AddField( - model_name='device', - name='longitude', - field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True), - ), - ] diff --git a/netbox/dcim/migrations/0174_rack_starting_unit.py b/netbox/dcim/migrations/0174_rack_starting_unit.py deleted file mode 100644 index 2d2b5f826..000000000 --- a/netbox/dcim/migrations/0174_rack_starting_unit.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.1.9 on 2023-05-31 15:47 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ('dcim', '0174_device_latitude_device_longitude'), - ] - - operations = [ - migrations.AddField( - model_name='rack', - name='starting_unit', - field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1)]), - ), - ] diff --git a/netbox/dcim/migrations/0175_device_oob_ip.py b/netbox/dcim/migrations/0175_device_oob_ip.py deleted file mode 100644 index bf6a88ba8..000000000 --- a/netbox/dcim/migrations/0175_device_oob_ip.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 4.1.9 on 2023-07-24 20:29 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - dependencies = [ - ('ipam', '0066_iprange_mark_utilized'), - ('dcim', '0174_rack_starting_unit'), - ] - - operations = [ - migrations.AddField( - model_name='device', - name='oob_ip', - field=models.OneToOneField( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name='+', - to='ipam.ipaddress', - ), - ), - ] diff --git a/netbox/dcim/migrations/0176_device_component_counters.py b/netbox/dcim/migrations/0176_device_component_counters.py deleted file mode 100644 index 60857ecb9..000000000 --- a/netbox/dcim/migrations/0176_device_component_counters.py +++ /dev/null @@ -1,83 +0,0 @@ -from django.db import migrations -from django.db.models import Count - -import utilities.fields -from utilities.counters import update_counts - - -def recalculate_device_counts(apps, schema_editor): - Device = apps.get_model("dcim", "Device") - - update_counts(Device, 'console_port_count', 'consoleports') - update_counts(Device, 'console_server_port_count', 'consoleserverports') - update_counts(Device, 'power_port_count', 'powerports') - update_counts(Device, 'power_outlet_count', 'poweroutlets') - update_counts(Device, 'interface_count', 'interfaces') - update_counts(Device, 'front_port_count', 'frontports') - update_counts(Device, 'rear_port_count', 'rearports') - update_counts(Device, 'device_bay_count', 'devicebays') - update_counts(Device, 'module_bay_count', 'modulebays') - update_counts(Device, 'inventory_item_count', 'inventoryitems') - - -class Migration(migrations.Migration): - dependencies = [ - ('dcim', '0175_device_oob_ip'), - ] - - operations = [ - migrations.AddField( - model_name='device', - name='console_port_count', - field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.ConsolePort'), - ), - migrations.AddField( - model_name='device', - name='console_server_port_count', - field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.ConsoleServerPort'), - ), - migrations.AddField( - model_name='device', - name='power_port_count', - field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.PowerPort'), - ), - migrations.AddField( - model_name='device', - name='power_outlet_count', - field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.PowerOutlet'), - ), - migrations.AddField( - model_name='device', - name='interface_count', - field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.Interface'), - ), - migrations.AddField( - model_name='device', - name='front_port_count', - field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.FrontPort'), - ), - migrations.AddField( - model_name='device', - name='rear_port_count', - field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.RearPort'), - ), - migrations.AddField( - model_name='device', - name='device_bay_count', - field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.DeviceBay'), - ), - migrations.AddField( - model_name='device', - name='module_bay_count', - field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.ModuleBay'), - ), - migrations.AddField( - model_name='device', - name='inventory_item_count', - field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.InventoryItem'), - ), - migrations.RunPython( - recalculate_device_counts, - reverse_code=migrations.RunPython.noop - ), - ] diff --git a/netbox/dcim/migrations/0177_devicetype_component_counters.py b/netbox/dcim/migrations/0177_devicetype_component_counters.py deleted file mode 100644 index b452ce2d9..000000000 --- a/netbox/dcim/migrations/0177_devicetype_component_counters.py +++ /dev/null @@ -1,83 +0,0 @@ -from django.db import migrations -from django.db.models import Count - -import utilities.fields -from utilities.counters import update_counts - - -def recalculate_devicetype_template_counts(apps, schema_editor): - DeviceType = apps.get_model("dcim", "DeviceType") - - update_counts(DeviceType, 'console_port_template_count', 'consoleporttemplates') - update_counts(DeviceType, 'console_server_port_template_count', 'consoleserverporttemplates') - update_counts(DeviceType, 'power_port_template_count', 'powerporttemplates') - update_counts(DeviceType, 'power_outlet_template_count', 'poweroutlettemplates') - update_counts(DeviceType, 'interface_template_count', 'interfacetemplates') - update_counts(DeviceType, 'front_port_template_count', 'frontporttemplates') - update_counts(DeviceType, 'rear_port_template_count', 'rearporttemplates') - update_counts(DeviceType, 'device_bay_template_count', 'devicebaytemplates') - update_counts(DeviceType, 'module_bay_template_count', 'modulebaytemplates') - update_counts(DeviceType, 'inventory_item_template_count', 'inventoryitemtemplates') - - -class Migration(migrations.Migration): - dependencies = [ - ('dcim', '0176_device_component_counters'), - ] - - operations = [ - migrations.AddField( - model_name='devicetype', - name='console_port_template_count', - field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.ConsolePortTemplate'), - ), - migrations.AddField( - model_name='devicetype', - name='console_server_port_template_count', - field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.ConsoleServerPortTemplate'), - ), - migrations.AddField( - model_name='devicetype', - name='power_port_template_count', - field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.PowerPortTemplate'), - ), - migrations.AddField( - model_name='devicetype', - name='power_outlet_template_count', - field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.PowerOutletTemplate'), - ), - migrations.AddField( - model_name='devicetype', - name='interface_template_count', - field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.InterfaceTemplate'), - ), - migrations.AddField( - model_name='devicetype', - name='front_port_template_count', - field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.FrontPortTemplate'), - ), - migrations.AddField( - model_name='devicetype', - name='rear_port_template_count', - field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.RearPortTemplate'), - ), - migrations.AddField( - model_name='devicetype', - name='device_bay_template_count', - field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.DeviceBayTemplate'), - ), - migrations.AddField( - model_name='devicetype', - name='module_bay_template_count', - field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.ModuleBayTemplate'), - ), - migrations.AddField( - model_name='devicetype', - name='inventory_item_template_count', - field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.InventoryItemTemplate'), - ), - migrations.RunPython( - recalculate_devicetype_template_counts, - reverse_code=migrations.RunPython.noop - ), - ] diff --git a/netbox/dcim/migrations/0178_virtual_chassis_member_counter.py b/netbox/dcim/migrations/0178_virtual_chassis_member_counter.py deleted file mode 100644 index 99b304b66..000000000 --- a/netbox/dcim/migrations/0178_virtual_chassis_member_counter.py +++ /dev/null @@ -1,31 +0,0 @@ -from django.db import migrations -from django.db.models import Count - -import utilities.fields -from utilities.counters import update_counts - - -def populate_virtualchassis_members(apps, schema_editor): - VirtualChassis = apps.get_model('dcim', 'VirtualChassis') - - update_counts(VirtualChassis, 'member_count', 'members') - - -class Migration(migrations.Migration): - dependencies = [ - ('dcim', '0177_devicetype_component_counters'), - ] - - operations = [ - migrations.AddField( - model_name='virtualchassis', - name='member_count', - field=utilities.fields.CounterCacheField( - default=0, to_field='virtual_chassis', to_model='dcim.Device' - ), - ), - migrations.RunPython( - code=populate_virtualchassis_members, - reverse_code=migrations.RunPython.noop - ), - ] diff --git a/netbox/dcim/migrations/0179_interfacetemplate_rf_role.py b/netbox/dcim/migrations/0179_interfacetemplate_rf_role.py deleted file mode 100644 index 44eb08853..000000000 --- a/netbox/dcim/migrations/0179_interfacetemplate_rf_role.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-18 07:55 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0178_virtual_chassis_member_counter'), - ] - - operations = [ - migrations.AddField( - model_name='interfacetemplate', - name='rf_role', - field=models.CharField(blank=True, max_length=30), - ), - ] diff --git a/netbox/dcim/migrations/0180_powerfeed_tenant.py b/netbox/dcim/migrations/0180_powerfeed_tenant.py deleted file mode 100644 index af550b21d..000000000 --- a/netbox/dcim/migrations/0180_powerfeed_tenant.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 4.1.8 on 2023-07-29 11:29 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('tenancy', '0010_tenant_relax_uniqueness'), - ('dcim', '0179_interfacetemplate_rf_role'), - ] - - operations = [ - migrations.AddField( - model_name='powerfeed', - name='tenant', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='power_feeds', to='tenancy.tenant'), - ), - ] diff --git a/netbox/dcim/migrations/0181_rename_device_role_device_role.py b/netbox/dcim/migrations/0181_rename_device_role_device_role.py deleted file mode 100644 index e32e00221..000000000 --- a/netbox/dcim/migrations/0181_rename_device_role_device_role.py +++ /dev/null @@ -1,35 +0,0 @@ -from django.db import migrations - - -def update_table_configs(apps, schema_editor): - """ - Replace the `device_role` column in DeviceTable configs with `role`. - """ - UserConfig = apps.get_model('users', 'UserConfig') - - for table in ('DeviceTable', 'DeviceBayTable'): - for config in UserConfig.objects.filter(**{f'data__tables__{table}__columns__contains': 'device_role'}): - config.data['tables'][table]['columns'] = [ - 'role' if x == 'device_role' else x - for x in config.data['tables'][table]['columns'] - ] - config.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0180_powerfeed_tenant'), - ] - - operations = [ - migrations.RenameField( - model_name='device', - old_name='device_role', - new_name='role', - ), - migrations.RunPython( - code=update_table_configs, - reverse_code=migrations.RunPython.noop - ), - ] diff --git a/netbox/dcim/migrations/0182_zero_length_cable_fix.py b/netbox/dcim/migrations/0182_zero_length_cable_fix.py deleted file mode 100644 index 080e00717..000000000 --- a/netbox/dcim/migrations/0182_zero_length_cable_fix.py +++ /dev/null @@ -1,22 +0,0 @@ -from django.db import migrations - - -def update_cable_lengths(apps, schema_editor): - Cable = apps.get_model('dcim', 'Cable') - - # Set the absolute length for any zero-length Cables - Cable.objects.filter(length=0).update(_abs_length=0) - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0181_rename_device_role_device_role'), - ] - - operations = [ - migrations.RunPython( - code=update_cable_lengths, - reverse_code=migrations.RunPython.noop - ), - ] diff --git a/netbox/dcim/migrations/0156_location_status.py b/netbox/dcim/migrations/0186_location_facility.py similarity index 51% rename from netbox/dcim/migrations/0156_location_status.py rename to netbox/dcim/migrations/0186_location_facility.py index b20273755..759ee813b 100644 --- a/netbox/dcim/migrations/0156_location_status.py +++ b/netbox/dcim/migrations/0186_location_facility.py @@ -1,18 +1,18 @@ -# Generated by Django 4.0.5 on 2022-06-22 17:10 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0155_interface_poe_mode_type'), - ] - - operations = [ - migrations.AddField( - model_name='location', - name='status', - field=models.CharField(default='active', max_length=50), - ), - ] +# Generated by Django 4.2.4 on 2024-03-17 02:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0185_gfk_indexes'), + ] + + operations = [ + migrations.AddField( + model_name='location', + name='facility', + field=models.CharField(blank=True, max_length=50), + ), + ] diff --git a/netbox/dcim/migrations/0187_alter_device_vc_position.py b/netbox/dcim/migrations/0187_alter_device_vc_position.py new file mode 100644 index 000000000..d4a42dc20 --- /dev/null +++ b/netbox/dcim/migrations/0187_alter_device_vc_position.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.3 on 2024-04-19 16:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0186_location_facility'), + ] + + operations = [ + migrations.AlterField( + model_name='device', + name='vc_position', + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 464735125..64f0b8560 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -9,15 +9,15 @@ from django.dispatch import Signal from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from core.models import ContentType +from core.models import ObjectType from dcim.choices import * from dcim.constants import * from dcim.fields import PathField from dcim.utils import decompile_path_node, object_to_path_node from netbox.models import ChangeLoggedModel, PrimaryModel +from utilities.conversion import to_meters from utilities.fields import ColorField from utilities.querysets import RestrictedQuerySet -from utilities.utils import to_meters from wireless.models import WirelessLink from .device_components import FrontPort, RearPort, PathEndpoint @@ -318,10 +318,14 @@ class CableTermination(ChangeLoggedModel): super().clean() # Check for existing termination - existing_termination = CableTermination.objects.exclude(cable=self.cable).filter( + qs = CableTermination.objects.filter( termination_type=self.termination_type, termination_id=self.termination_id - ).first() + ) + if self.cable.pk: + qs = qs.exclude(cable=self.cable) + + existing_termination = qs.first() if existing_termination is not None: raise ValidationError( _("Duplicate termination found for {app_label}.{model} {termination_id}: cable {cable_pk}".format( @@ -477,13 +481,13 @@ class CablePath(models.Model): def origin_type(self): if self.path: ct_id, _ = decompile_path_node(self.path[0][0]) - return ContentType.objects.get_for_id(ct_id) + return ObjectType.objects.get_for_id(ct_id) @property def destination_type(self): if self.is_complete: ct_id, _ = decompile_path_node(self.path[-1][0]) - return ContentType.objects.get_for_id(ct_id) + return ObjectType.objects.get_for_id(ct_id) @property def path_objects(self): @@ -590,7 +594,7 @@ class CablePath(models.Model): # Step 6: Determine the far-end terminations if isinstance(links[0], Cable): - termination_type = ContentType.objects.get_for_model(terminations[0]) + 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] @@ -743,7 +747,7 @@ class CablePath(models.Model): # Prefetch path objects using one query per model type. Prefetch related devices where appropriate. prefetched = {} for ct_id, object_ids in to_prefetch.items(): - model_class = ContentType.objects.get_for_id(ct_id).model_class() + model_class = ObjectType.objects.get_for_id(ct_id).model_class() queryset = model_class.objects.filter(pk__in=object_ids) if hasattr(model_class, 'device'): queryset = queryset.prefetch_related('device') @@ -770,7 +774,7 @@ class CablePath(models.Model): """ Return all Cable IDs within the path. """ - cable_ct = ContentType.objects.get_for_model(Cable).pk + cable_ct = ObjectType.objects.get_for_model(Cable).pk cable_ids = [] for node in self._nodes: diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 5b2564b32..9438b741f 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -12,8 +12,8 @@ from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import * from dcim.constants import * from dcim.fields import MACAddressField, WWNField +from netbox.choices import ColorChoices from netbox.models import OrganizationalModel, NetBoxModel -from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.mptt import TreeManager from utilities.ordering import naturalize_interface diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index f9e8ba213..abc9e0b08 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -18,10 +18,10 @@ from dcim.choices import * from dcim.constants import * from extras.models import ConfigContextModel, CustomField from extras.querysets import ConfigContextModelQuerySet +from netbox.choices import ColorChoices from netbox.config import ConfigItem from netbox.models import OrganizationalModel, PrimaryModel from netbox.models.features import ContactsMixin, ImageAttachmentsMixin -from utilities.choices import ColorChoices from utilities.fields import ColorField, CounterCacheField, NaturalOrderingField from utilities.tracking import TrackingModelMixin from .device_components import * @@ -222,22 +222,23 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): @property def get_full_name(self): - return f"{ self.manufacturer } { self.model }" + return f"{self.manufacturer} {self.model}" def to_yaml(self): data = { 'manufacturer': self.manufacturer.name, 'model': self.model, 'slug': self.slug, + 'description': self.description, 'default_platform': self.default_platform.name if self.default_platform else None, 'part_number': self.part_number, 'u_height': float(self.u_height), 'is_full_depth': self.is_full_depth, 'subdevice_role': self.subdevice_role, 'airflow': self.airflow, - 'comments': self.comments, 'weight': float(self.weight) if self.weight is not None else None, 'weight_unit': self.weight_unit, + 'comments': self.comments, } # Component templates @@ -415,9 +416,10 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): 'manufacturer': self.manufacturer.name, 'model': self.model, 'part_number': self.part_number, - 'comments': self.comments, + 'description': self.description, 'weight': float(self.weight) if self.weight is not None else None, 'weight_unit': self.weight_unit, + 'comments': self.comments, } # Component templates @@ -687,11 +689,10 @@ class Device( blank=True, null=True ) - vc_position = models.PositiveSmallIntegerField( + vc_position = models.PositiveIntegerField( verbose_name=_('VC position'), blank=True, null=True, - validators=[MaxValueValidator(255)], help_text=_('Virtual chassis position') ) vc_priority = models.PositiveSmallIntegerField( @@ -815,20 +816,6 @@ class Device( def get_absolute_url(self): return reverse('dcim:device', args=[self.pk]) - @property - def device_role(self): - """ - For backwards compatibility with pre-v3.6 code expecting a device_role to be present on Device. - """ - return self.role - - @device_role.setter - def device_role(self, value): - """ - For backwards compatibility with pre-v3.6 code expecting a device_role to be present on Device. - """ - self.role = value - def clean(self): super().clean() @@ -994,17 +981,16 @@ class Device( bulk_create: If True, bulk_create() will be called to create all components in a single query (default). Otherwise, save() will be called on each instance individually. """ - components = [obj.instantiate(device=self) for obj in queryset] - if not components: - return - - # Set default values for any applicable custom fields model = queryset.model.component_model - if cf_defaults := CustomField.objects.get_defaults_for_model(model): - for component in components: - component.custom_field_data = cf_defaults if bulk_create: + components = [obj.instantiate(device=self) for obj in queryset] + if not components: + return + # Set default values for any applicable custom fields + if cf_defaults := CustomField.objects.get_defaults_for_model(model): + for component in components: + component.custom_field_data = cf_defaults model.objects.bulk_create(components) # Manually send the post_save signal for each of the newly created components for component in components: @@ -1017,7 +1003,11 @@ class Device( update_fields=None ) else: - for component in components: + for obj in queryset: + component = obj.instantiate(device=self) + # Set default values for any applicable custom fields + if cf_defaults := CustomField.objects.get_defaults_for_model(model): + component.custom_field_data = cf_defaults component.save() def save(self, *args, **kwargs): @@ -1098,7 +1088,7 @@ class Device( :param if_master: If True, return VC member interfaces only if this Device is the VC master. """ - filter = Q(device=self) + filter = Q(device=self) if self.pk else Q() if self.virtual_chassis and (self.virtual_chassis.master == self or not if_master): filter |= Q(device__virtual_chassis=self.virtual_chassis, mgmt_only=False) return Interface.objects.filter(filter) diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py index 9be8dc0a3..d4a05699c 100644 --- a/netbox/dcim/models/mixins.py +++ b/netbox/dcim/models/mixins.py @@ -2,7 +2,7 @@ from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ from dcim.choices import * -from utilities.utils import to_grams +from utilities.conversion import to_grams __all__ = ( 'RenderConfigMixin', diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 62578d6c4..826eaae9c 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -84,6 +84,7 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel): rack = models.ForeignKey( to='Rack', on_delete=models.PROTECT, + related_name='powerfeeds', blank=True, null=True ) diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 3cb4e0225..289c38133 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -14,11 +14,12 @@ from django.utils.translation import gettext_lazy as _ from dcim.choices import * from dcim.constants import * from dcim.svg import RackElevationSVG +from netbox.choices import ColorChoices from netbox.models import OrganizationalModel, PrimaryModel from netbox.models.features import ContactsMixin, ImageAttachmentsMixin -from utilities.choices import ColorChoices +from utilities.conversion import to_grams +from utilities.data import array_to_string, drange from utilities.fields import ColorField, NaturalOrderingField -from utilities.utils import array_to_string, drange, to_grams from .device_components import PowerPort from .devices import Device, Module from .mixins import WeightMixin diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index d2797bf95..c1da807ad 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -275,6 +275,12 @@ class Location(ContactsMixin, ImageAttachmentsMixin, NestedGroupModel): blank=True, null=True ) + facility = models.CharField( + verbose_name=_('facility'), + max_length=50, + blank=True, + help_text=_('Local facility ID or description') + ) # Generic relations vlan_groups = GenericRelation( @@ -284,7 +290,7 @@ class Location(ContactsMixin, ImageAttachmentsMixin, NestedGroupModel): related_query_name='location' ) - clone_fields = ('site', 'parent', 'status', 'tenant', 'description') + clone_fields = ('site', 'parent', 'status', 'tenant', 'facility', 'description') prerequisite_models = ( 'dcim.Site', ) diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py index 18cf75a9a..b349bcac0 100644 --- a/netbox/dcim/search.py +++ b/netbox/dcim/search.py @@ -132,10 +132,11 @@ class LocationIndex(SearchIndex): model = models.Location fields = ( ('name', 100), + ('facility', 100), ('slug', 110), ('description', 500), ) - display_attrs = ('site', 'status', 'tenant', 'description') + display_attrs = ('site', 'status', 'tenant', 'facility', 'description') @register_search diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py index d7365161e..9ce5d967b 100644 --- a/netbox/dcim/svg/cables.py +++ b/netbox/dcim/svg/cables.py @@ -6,19 +6,18 @@ from svgwrite.text import Text from django.conf import settings from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH -from utilities.utils import foreground_color - +from utilities.html import foreground_color __all__ = ( 'CableTraceSVG', ) - OFFSET = 0.5 PADDING = 10 LINE_HEIGHT = 20 FANOUT_HEIGHT = 35 FANOUT_LEG_HEIGHT = 15 +CABLE_HEIGHT = 5 * LINE_HEIGHT + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT class Node(Hyperlink): @@ -84,31 +83,38 @@ class Connector(Group): labels: Iterable of text labels """ - def __init__(self, start, url, color, labels=[], description=[], **extra): - super().__init__(class_='connector', **extra) + def __init__(self, start, url, color, wireless, labels=[], description=[], end=None, text_offset=0, **extra): + super().__init__(class_="connector", **extra) self.start = start self.height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2 - self.end = (start[0], start[1] + self.height) + # Allow to specify end-position or auto-calculate + self.end = end if end else (start[0], start[1] + self.height) self.color = color or '000000' - # Draw a "shadow" line to give the cable a border - cable_shadow = Line(start=self.start, end=self.end, class_='cable-shadow') - self.add(cable_shadow) + if wireless: + # Draw the cable + cable = Line(start=self.start, end=self.end, class_="wireless-link") + self.add(cable) + else: + # Draw a "shadow" line to give the cable a border + cable_shadow = Line(start=self.start, end=self.end, class_='cable-shadow') + self.add(cable_shadow) - # Draw the cable - cable = Line(start=self.start, end=self.end, style=f'stroke: #{self.color}') - self.add(cable) + # Draw the cable + cable = Line(start=self.start, end=self.end, style=f'stroke: #{self.color}') + self.add(cable) # Add link link = Hyperlink(href=url, target='_parent') # Add text label(s) - cursor = start[1] - cursor += PADDING * 2 + cursor = start[1] + text_offset + cursor += PADDING * 2 + LINE_HEIGHT * 2 + x_coord = (start[0] + end[0]) / 2 + PADDING for i, label in enumerate(labels): cursor += LINE_HEIGHT - text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2) + text_coords = (x_coord, cursor - LINE_HEIGHT / 2) text = Text(label, insert=text_coords, class_='bold' if not i else []) link.add(text) if len(description) > 0: @@ -190,8 +196,9 @@ class CableTraceSVG: def draw_parent_objects(self, obj_list): """ - Draw a set of parent objects. + Draw a set of parent objects (eg hosts, switched, patchpanels) and return all created nodes """ + objects = [] width = self.width / len(obj_list) for i, obj in enumerate(obj_list): node = Node( @@ -199,23 +206,26 @@ class CableTraceSVG: width=width, url=f'{self.base_url}{obj.get_absolute_url()}', color=self._get_color(obj), - labels=self._get_labels(obj) + labels=self._get_labels(obj), + object=obj ) + objects.append(node) self.parent_objects.append(node) if i + 1 == len(obj_list): self.cursor += node.box['height'] + return objects - def draw_terminations(self, terminations): + def draw_object_terminations(self, terminations, offset_x, width): """ - Draw a row of terminating objects (e.g. interfaces), all of which are attached to the same end of a cable. + Draw all terminations belonging to an object with specified offset and width + Return all created nodes and their maximum height """ - nodes = [] nodes_height = 0 - width = self.width / len(terminations) - - for i, term in enumerate(terminations): + nodes = [] + # Sort them by name to make renders more readable + for i, term in enumerate(sorted(terminations, key=lambda x: str(x))): node = Node( - position=(i * width, self.cursor), + position=(offset_x + i * width, self.cursor), width=width, url=f'{self.base_url}{term.get_absolute_url()}', color=self._get_color(term), @@ -225,133 +235,89 @@ class CableTraceSVG: ) nodes_height = max(nodes_height, node.box['height']) nodes.append(node) + return nodes, nodes_height + + def draw_terminations(self, terminations, parent_object_nodes): + """ + Draw a row of terminating objects (e.g. interfaces) and return all created nodes + Attach them to previously created parent objects + """ + nodes = [] + nodes_height = 0 + + # Draw terminations for each parent object + for parent in parent_object_nodes: + parent_terms = [term for term in terminations if term.parent_object == parent.object] + + # Width and offset(position) for each termination box + width = parent.box['width'] / len(parent_terms) + offset_x = parent.box['x'] + + result, nodes_height = self.draw_object_terminations(parent_terms, offset_x, width) + nodes.extend(result) self.cursor += nodes_height self.terminations.extend(nodes) return nodes - def draw_fanin(self, node, connector): - points = ( - node.bottom_center, - (node.bottom_center[0], node.bottom_center[1] + FANOUT_LEG_HEIGHT), - connector.start, - ) - self.connectors.extend(( - Polyline(points=points, class_='cable-shadow'), - Polyline(points=points, style=f'stroke: #{connector.color}'), - )) - - def draw_fanout(self, node, connector): - points = ( - connector.end, - (node.top_center[0], node.top_center[1] - FANOUT_LEG_HEIGHT), - node.top_center, - ) - self.connectors.extend(( - Polyline(points=points, class_='cable-shadow'), - Polyline(points=points, style=f'stroke: #{connector.color}'), - )) - - def draw_cable(self, cable, terminations, cable_count=0): + def draw_far_objects(self, obj_list, terminations): """ - Draw a single cable. Terminations and cable count are passed for determining position and padding - - :param cable: The cable to draw - :param terminations: List of terminations to build positioning data off of - :param cable_count: Count of all cables on this layer for determining whether to collapse description into a - tooltip. + Draw the far-end objects and its terminations and return all created nodes """ + # Make sure elements are sorted by name for readability + objects = sorted(obj_list, key=lambda x: str(x)) + width = self.width / len(objects) - # If the cable count is higher than 2, collapse the description into a tooltip - if cable_count > 2: - # Use the cable __str__ function to denote the cable - labels = [f'{cable}'] + # Max-height of created terminations + terms_height = 0 + term_nodes = [] - # Include the label and the status description in the tooltip - description = [ - f'Cable {cable}', - cable.get_status_display() - ] + # Draw the terminations by per object first + for i, obj in enumerate(objects): + obj_terms = [term for term in terminations if term.parent_object == obj] + obj_pos = i * width + result, result_nodes_height = self.draw_object_terminations(obj_terms, obj_pos, width / len(obj_terms)) - if cable.type: - # Include the cable type in the tooltip - description.append(cable.get_type_display()) - if cable.length is not None and cable.length_unit: - # Include the cable length in the tooltip - description.append(f'{cable.length} {cable.get_length_unit_display()}') - else: - labels = [ - f'Cable {cable}', - cable.get_status_display() - ] - description = [] - if cable.type: - labels.append(cable.get_type_display()) - if cable.length is not None and cable.length_unit: - # Include the cable length in the tooltip - labels.append(f'{cable.length} {cable.get_length_unit_display()}') + terms_height = max(terms_height, result_nodes_height) + term_nodes.extend(result) - # If there is only one termination, center on that termination - # Otherwise average the center across the terminations - if len(terminations) == 1: - center = terminations[0].bottom_center[0] - else: - # Get a list of termination centers - termination_centers = [term.bottom_center[0] for term in terminations] - # Average the centers - center = sum(termination_centers) / len(termination_centers) + # Update cursor and draw the objects + self.cursor += terms_height + self.terminations.extend(term_nodes) + object_nodes = self.draw_parent_objects(objects) - # Create the connector - connector = Connector( - start=(center, self.cursor), - color=cable.color or '000000', - url=f'{self.base_url}{cable.get_absolute_url()}', - labels=labels, - description=description - ) + return object_nodes, term_nodes - # Set the cursor position - self.cursor += connector.height - - return connector - - def draw_wirelesslink(self, wirelesslink): + def draw_fanin(self, target, terminations, color): """ - Draw a line with labels representing a WirelessLink. + Draw the fan-in-lines from each of the terminations to the targetpoint """ - group = Group(class_='connector') + for term in terminations: + points = ( + term.bottom_center, + (term.bottom_center[0], term.bottom_center[1] + FANOUT_LEG_HEIGHT), + target, + ) + self.connectors.extend(( + Polyline(points=points, class_='cable-shadow'), + Polyline(points=points, style=f'stroke: #{color}'), + )) - labels = [ - f'Wireless link {wirelesslink}', - wirelesslink.get_status_display() - ] - if wirelesslink.ssid: - labels.append(wirelesslink.ssid) - - # Draw the wireless link - start = (OFFSET + self.center, self.cursor) - height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2 - end = (start[0], start[1] + height) - line = Line(start=start, end=end, class_='wireless-link') - group.add(line) - - self.cursor += PADDING * 2 - - # Add link - link = Hyperlink(href=f'{self.base_url}{wirelesslink.get_absolute_url()}', target='_parent') - - # Add text label(s) - for i, label in enumerate(labels): - self.cursor += LINE_HEIGHT - text_coords = (self.center + PADDING * 2, self.cursor - LINE_HEIGHT / 2) - text = Text(label, insert=text_coords, class_='bold' if not i else []) - link.add(text) - - group.add(link) - self.cursor += PADDING * 2 - - return group + def draw_fanout(self, start, terminations, color): + """ + Draw the fan-out-lines from the startpoint to each of the terminations + """ + for term in terminations: + points = ( + term.top_center, + (term.top_center[0], term.top_center[1] - FANOUT_LEG_HEIGHT), + start, + ) + self.connectors.extend(( + Polyline(points=points, class_='cable-shadow'), + Polyline(points=points, style=f'stroke: #{color}'), + )) def draw_attachment(self): """ @@ -378,86 +344,110 @@ class CableTraceSVG: traced_path = self.origin.trace() + parent_object_nodes = [] # Iterate through each (terms, cable, terms) segment in the path for i, segment in enumerate(traced_path): near_ends, links, far_ends = segment - # Near end parent + # This is segment number one. if i == 0: # If this is the first segment, draw the originating termination's parent object - self.draw_parent_objects(set(end.parent_object for end in near_ends)) + parent_object_nodes = self.draw_parent_objects(set(end.parent_object for end in near_ends)) + # Else: No need to draw parent objects (parent objects are drawn in last "round" as the far-end!) - # Near end termination(s) - terminations = self.draw_terminations(near_ends) + near_terminations = self.draw_terminations(near_ends, parent_object_nodes) + self.cursor += CABLE_HEIGHT # Connector (a Cable or WirelessLink) if links: - link_cables = {} - fanin = False - fanout = False - # Determine if we have fanins or fanouts - if len(near_ends) > len(set(links)): - self.cursor += FANOUT_HEIGHT - fanin = True - if len(far_ends) > len(set(links)): - fanout = True - cursor = self.cursor - for link in links: - # Cable - if type(link) is Cable and not link_cables.get(link.pk): - # Reset cursor - self.cursor = cursor - # Generate a list of terminations connected to this cable - near_end_link_terminations = [term for term in terminations if term.object.cable == link] - # Draw the cable - cable = self.draw_cable(link, near_end_link_terminations, cable_count=len(links)) - # Add cable to the list of cables - link_cables.update({link.pk: cable}) - # Add cable to drawing - self.connectors.append(cable) + obj_list = {end.parent_object for end in far_ends} + parent_object_nodes, far_terminations = self.draw_far_objects(obj_list, far_ends) + for cable in links: + # Fill in labels and description with all available data + description = [ + f"Link {cable}", + cable.get_status_display() + ] + near = [] + far = [] + color = '000000' + if cable.description: + description.append(f"{cable.description}") + if isinstance(cable, Cable): + labels = [f"{cable}"] if len(links) > 2 else [f"Cable {cable}", cable.get_status_display()] + if cable.type: + description.append(cable.get_type_display()) + if cable.length and cable.length_unit: + description.append(f"{cable.length} {cable.get_length_unit_display()}") + color = cable.color or '000000' - # Draw fan-ins - if len(near_ends) > 1 and fanin: - for term in terminations: - if term.object.cable == link: - self.draw_fanin(term, cable) + # Collect all connected nodes to this cable + near = [term for term in near_terminations if term.object in cable.a_terminations] + far = [term for term in far_terminations if term.object in cable.b_terminations] + if not (near and far): + # a and b terminations may be swapped + near = [term for term in near_terminations if term.object in cable.b_terminations] + far = [term for term in far_terminations if term.object in cable.a_terminations] + elif isinstance(cable, WirelessLink): + labels = [f"{cable}"] if len(links) > 2 else [f"Wireless {cable}", cable.get_status_display()] + if cable.ssid: + description.append(f"{cable.ssid}") + near = [term for term in near_terminations if term.object == cable.interface_a] + far = [term for term in far_terminations if term.object == cable.interface_b] + if not (near and far): + # a and b terminations may be swapped + near = [term for term in near_terminations if term.object == cable.interface_b] + far = [term for term in far_terminations if term.object == cable.interface_a] - # WirelessLink - elif type(link) is WirelessLink: - wirelesslink = self.draw_wirelesslink(link) - self.connectors.append(wirelesslink) + # Select most-probable start and end position + start = near[0].bottom_center + end = far[0].top_center + text_offset = 0 - # Far end termination(s) - if len(far_ends) > 1: - if fanout: - self.cursor += FANOUT_HEIGHT - terminations = self.draw_terminations(far_ends) - for term in terminations: - if hasattr(term.object, 'cable') and link_cables.get(term.object.cable.pk): - self.draw_fanout(term, link_cables.get(term.object.cable.pk)) - else: - self.draw_terminations(far_ends) - elif far_ends: - self.draw_terminations(far_ends) - else: - # Link is not connected to anything - break + if len(near) > 1 and len(far) > 1: + start_center = sum([pos.bottom_center[0] for pos in near]) / len(near) + end_center = sum([pos.bottom_center[0] for pos in far]) / len(far) + center_x = (start_center + end_center) / 2 - # Far end parent - parent_objects = set(end.parent_object for end in far_ends) - self.draw_parent_objects(parent_objects) + start = (center_x, start[1] + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT) + end = (center_x, end[1] - FANOUT_HEIGHT - FANOUT_LEG_HEIGHT) + text_offset -= (FANOUT_HEIGHT + FANOUT_LEG_HEIGHT) + self.draw_fanin(start, near, color) + self.draw_fanout(end, far, color) + elif len(near) > 1: + # Handle Fan-In - change start position to be directly below start + start = (end[0], start[1] + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT) + self.draw_fanin(start, near, color) + text_offset -= FANOUT_HEIGHT + FANOUT_LEG_HEIGHT + elif len(far) > 1: + # Handle Fan-Out - change end position to be directly above end + end = (start[0], end[1] - FANOUT_HEIGHT - FANOUT_LEG_HEIGHT) + self.draw_fanout(end, far, color) + text_offset -= FANOUT_HEIGHT + + # Create the connector + connector = Connector( + start=start, + end=end, + color=color, + wireless=isinstance(cable, WirelessLink), + url=f'{self.base_url}{cable.get_absolute_url()}', + text_offset=text_offset, + labels=labels, + description=description + ) + self.connectors.append(connector) # Render a far-end object not connected via a link (e.g. a ProviderNetwork or Site associated with # a CircuitTermination) elif far_ends: - # Attachment attachment = self.draw_attachment() self.connectors.append(attachment) # Object - self.draw_parent_objects(far_ends) + parent_object_nodes = self.draw_parent_objects(far_ends) # Determine drawing size self.drawing = svgwrite.Drawing( diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py index 07ea55a33..0f73095b5 100644 --- a/netbox/dcim/svg/racks.py +++ b/netbox/dcim/svg/racks.py @@ -14,7 +14,8 @@ from django.urls import reverse from django.utils.http import urlencode from netbox.config import get_config -from utilities.utils import foreground_color, array_to_ranges +from utilities.data import array_to_ranges +from utilities.html import foreground_color from dcim.constants import RACK_ELEVATION_BORDER_WIDTH diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index d4c9641b6..4925fb517 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -51,34 +51,6 @@ def get_cabletermination_row_class(record): return '' -def get_interface_row_class(record): - if not record.enabled: - return 'danger' - elif record.is_virtual: - return 'primary' - return get_cabletermination_row_class(record) - - -def get_interface_state_attribute(record): - """ - Get interface enabled state as string to attach to DOM element. - """ - if record.enabled: - return 'enabled' - else: - return 'disabled' - - -def get_interface_connected_attribute(record): - """ - Get interface disconnected state as string to attach to DOM element. - """ - if record.mark_connected or record.cable: - return 'connected' - else: - return 'disconnected' - - # # Device roles # @@ -210,6 +182,10 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): linkify=True, verbose_name=_('Type') ) + platform = tables.Column( + linkify=True, + verbose_name=_('Platform') + ) primary_ip = tables.Column( linkify=True, order_by=('primary_ip4', 'primary_ip6'), @@ -294,7 +270,7 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): model = models.Device fields = ( 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'role', 'manufacturer', 'device_type', - 'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device', + 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device', 'device_bay_position', 'position', 'face', 'latitude', 'longitude', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated', @@ -337,6 +313,10 @@ class ModularDeviceComponentTable(DeviceComponentTable): verbose_name=_('Module'), linkify=True ) + inventory_items = columns.ManyToManyColumn( + linkify_item=True, + verbose_name=_('Inventory Items'), + ) class CableTerminationTable(NetBoxTable): @@ -390,7 +370,7 @@ class ConsolePortTable(ModularDeviceComponentTable, PathEndpointTable): model = models.ConsolePort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description', - 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'inventory_items', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description') @@ -434,7 +414,7 @@ class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable): model = models.ConsoleServerPort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description', - 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'inventory_items', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description') @@ -485,8 +465,8 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable): model = models.PowerPort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'mark_connected', - 'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', - 'last_updated', + 'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'inventory_items', + 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') @@ -537,8 +517,8 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable): model = models.PowerOutlet fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'power_port', - 'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', - 'last_updated', + 'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'inventory_items', + 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description') @@ -642,10 +622,6 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi verbose_name=_('VRF'), linkify=True ) - inventory_items = tables.ManyToManyColumn( - linkify_item=True, - verbose_name=_('Inventory Items'), - ) tags = columns.TagColumn( url_name='dcim:interface_list' ) @@ -702,11 +678,12 @@ class DeviceInterfaceTable(InterfaceTable): 'cable', 'connection', ) row_attrs = { - 'class': get_interface_row_class, 'data-name': lambda record: record.name, - 'data-enabled': get_interface_state_attribute, - 'data-type': lambda record: record.type, - 'data-connected': get_interface_connected_attribute + 'data-enabled': lambda record: "enabled" if record.enabled else "disabled", + 'data-virtual': lambda record: "true" if record.is_virtual else "false", + 'data-mark-connected': lambda record: "true" if record.mark_connected else "false", + 'data-cable-status': lambda record: record.cable.status if record.cable else "", + 'data-type': lambda record: record.type } @@ -736,8 +713,8 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable): model = models.FrontPort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'rear_port', - 'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', - 'created', 'last_updated', + 'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', + 'inventory_items', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', @@ -789,7 +766,7 @@ class RearPortTable(ModularDeviceComponentTable, CableTerminationTable): model = models.RearPort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'description', - 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'created', 'last_updated', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'inventory_items', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description') diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index a0a71ab30..e179ec43a 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -152,7 +152,9 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Location fields = ( - 'pk', 'id', 'name', 'site', 'status', 'tenant', 'tenant_group', 'rack_count', 'device_count', 'description', - 'slug', 'contacts', 'tags', 'actions', 'created', 'last_updated', + 'pk', 'id', 'name', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count', 'device_count', + 'description', 'slug', 'contacts', 'tags', 'actions', 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'name', 'site', 'status', 'facility', 'tenant', 'rack_count', 'device_count', 'description' ) - default_columns = ('pk', 'name', 'site', 'status', 'tenant', 'rack_count', 'device_count', 'description') diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index de27d67ad..d3134656c 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -21,41 +21,37 @@ WEIGHT = """ """ DEVICE_LINK = """ -{{ value|default:'Unnamed device' }} +{{ value|default:'Unnamed device' }} """ DEVICEBAY_STATUS = """ {% if record.installed_device_id %} - + {{ record.installed_device.get_status_display }} {% else %} - Vacant + Vacant {% endif %} """ INTERFACE_IPADDRESSES = """ -
- {% if value.count >= 3 %} + {% if value.count > 3 %} {{ value.count }} {% else %} {% for ip in value.all %} {% if ip.status != 'active' %} - {{ ip }} + {{ ip }} {% else %} - {{ ip }} + {{ ip }} {% endif %} {% endfor %} {% endif %} -
""" INTERFACE_FHRPGROUPS = """ - """ INTERFACE_TAGGED_VLANS = """ diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index d67516848..c2d14242c 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -10,6 +10,7 @@ from dcim.models import * from extras.models import ConfigTemplate from ipam.models import ASN, RIR, VLAN, VRF from netbox.api.serializers import GenericObjectSerializer +from tenancy.models import Tenant from utilities.testing import APITestCase, APIViewTestCases, create_test_device from virtualization.models import Cluster, ClusterType from wireless.choices import WirelessChannelChoices @@ -68,7 +69,7 @@ class Mixins: class RegionTest(APIViewTestCases.APIViewTestCase): model = Region - brief_fields = ['_depth', 'display', 'id', 'name', 'site_count', 'slug', 'url'] + brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'site_count', 'slug', 'url'] create_data = [ { 'name': 'Region 4', @@ -97,7 +98,7 @@ class RegionTest(APIViewTestCases.APIViewTestCase): class SiteGroupTest(APIViewTestCases.APIViewTestCase): model = SiteGroup - brief_fields = ['_depth', 'display', 'id', 'name', 'site_count', 'slug', 'url'] + brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'site_count', 'slug', 'url'] create_data = [ { 'name': 'Site Group 4', @@ -126,7 +127,7 @@ class SiteGroupTest(APIViewTestCases.APIViewTestCase): class SiteTest(APIViewTestCases.APIViewTestCase): model = Site - brief_fields = ['display', 'id', 'name', 'slug', 'url'] + brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url'] bulk_update_data = { 'status': 'planned', } @@ -152,6 +153,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase): Site.objects.bulk_create(sites) rir = RIR.objects.create(name='RFC 6996', is_private=True) + tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1') asns = [ ASN(asn=65000 + i, rir=rir) for i in range(8) @@ -166,6 +168,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase): 'group': groups[1].pk, 'status': SiteStatusChoices.STATUS_ACTIVE, 'asns': [asns[0].pk, asns[1].pk], + 'tenant': tenant.pk, }, { 'name': 'Site 5', @@ -188,7 +191,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase): class LocationTest(APIViewTestCases.APIViewTestCase): model = Location - brief_fields = ['_depth', 'display', 'id', 'name', 'rack_count', 'slug', 'url'] + brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'rack_count', 'slug', 'url'] bulk_update_data = { 'description': 'New description', } @@ -230,7 +233,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase): 'name': 'Test Location 6', 'slug': 'test-location-6', 'site': sites[1].pk, - 'parent': parent_locations[1].pk, + # Omit parent to test uniqueness constraint 'status': LocationStatusChoices.STATUS_PLANNED, }, ] @@ -238,7 +241,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase): class RackRoleTest(APIViewTestCases.APIViewTestCase): model = RackRole - brief_fields = ['display', 'id', 'name', 'rack_count', 'slug', 'url'] + brief_fields = ['description', 'display', 'id', 'name', 'rack_count', 'slug', 'url'] create_data = [ { 'name': 'Rack Role 4', @@ -273,7 +276,7 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase): class RackTest(APIViewTestCases.APIViewTestCase): model = Rack - brief_fields = ['device_count', 'display', 'id', 'name', 'url'] + brief_fields = ['description', 'device_count', 'display', 'id', 'name', 'url'] bulk_update_data = { 'status': 'planned', } @@ -361,7 +364,7 @@ class RackTest(APIViewTestCases.APIViewTestCase): class RackReservationTest(APIViewTestCases.APIViewTestCase): model = RackReservation - brief_fields = ['display', 'id', 'units', 'url', 'user'] + brief_fields = ['description', 'display', 'id', 'units', 'url', 'user'] bulk_update_data = { 'description': 'New description', } @@ -408,7 +411,7 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase): class ManufacturerTest(APIViewTestCases.APIViewTestCase): model = Manufacturer - brief_fields = ['devicetype_count', 'display', 'id', 'name', 'slug', 'url'] + brief_fields = ['description', 'devicetype_count', 'display', 'id', 'name', 'slug', 'url'] create_data = [ { 'name': 'Manufacturer 4', @@ -440,7 +443,7 @@ class ManufacturerTest(APIViewTestCases.APIViewTestCase): class DeviceTypeTest(APIViewTestCases.APIViewTestCase): model = DeviceType - brief_fields = ['device_count', 'display', 'id', 'manufacturer', 'model', 'slug', 'url'] + brief_fields = ['description', 'device_count', 'display', 'id', 'manufacturer', 'model', 'slug', 'url'] bulk_update_data = { 'part_number': 'ABC123', } @@ -485,7 +488,7 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase): class ModuleTypeTest(APIViewTestCases.APIViewTestCase): model = ModuleType - brief_fields = ['display', 'id', 'manufacturer', 'model', 'url'] + brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'url'] bulk_update_data = { 'part_number': 'ABC123', } @@ -524,7 +527,7 @@ class ModuleTypeTest(APIViewTestCases.APIViewTestCase): class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase): model = ConsolePortTemplate - brief_fields = ['display', 'id', 'name', 'url'] + brief_fields = ['description', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -568,7 +571,7 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase): class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase): model = ConsoleServerPortTemplate - brief_fields = ['display', 'id', 'name', 'url'] + brief_fields = ['description', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -612,7 +615,7 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase): class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase): model = PowerPortTemplate - brief_fields = ['display', 'id', 'name', 'url'] + brief_fields = ['description', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -656,7 +659,7 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase): class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase): model = PowerOutletTemplate - brief_fields = ['display', 'id', 'name', 'url'] + brief_fields = ['description', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -713,7 +716,7 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase): class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase): model = InterfaceTemplate - brief_fields = ['display', 'id', 'name', 'url'] + brief_fields = ['description', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -761,7 +764,7 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase): class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): model = FrontPortTemplate - brief_fields = ['display', 'id', 'name', 'url'] + brief_fields = ['description', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -850,7 +853,7 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): class RearPortTemplateTest(APIViewTestCases.APIViewTestCase): model = RearPortTemplate - brief_fields = ['display', 'id', 'name', 'url'] + brief_fields = ['description', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -898,7 +901,7 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase): class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase): model = ModuleBayTemplate - brief_fields = ['display', 'id', 'name', 'url'] + brief_fields = ['description', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -938,7 +941,7 @@ class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase): class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase): model = DeviceBayTemplate - brief_fields = ['display', 'id', 'name', 'url'] + brief_fields = ['description', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -978,7 +981,7 @@ class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase): class InventoryItemTemplateTest(APIViewTestCases.APIViewTestCase): model = InventoryItemTemplate - brief_fields = ['_depth', 'display', 'id', 'name', 'url'] + brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1029,7 +1032,7 @@ class InventoryItemTemplateTest(APIViewTestCases.APIViewTestCase): class DeviceRoleTest(APIViewTestCases.APIViewTestCase): model = DeviceRole - brief_fields = ['device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count'] + brief_fields = ['description', 'device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count'] create_data = [ { 'name': 'Device Role 4', @@ -1064,7 +1067,7 @@ class DeviceRoleTest(APIViewTestCases.APIViewTestCase): class PlatformTest(APIViewTestCases.APIViewTestCase): model = Platform - brief_fields = ['device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count'] + brief_fields = ['description', 'device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count'] create_data = [ { 'name': 'Platform 4', @@ -1096,7 +1099,7 @@ class PlatformTest(APIViewTestCases.APIViewTestCase): class DeviceTest(APIViewTestCases.APIViewTestCase): model = Device - brief_fields = ['display', 'id', 'name', 'url'] + brief_fields = ['description', 'display', 'id', 'name', 'url'] bulk_update_data = { 'status': 'failed', } @@ -1286,7 +1289,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): class ModuleTest(APIViewTestCases.APIViewTestCase): model = Module - brief_fields = ['device', 'display', 'id', 'module_bay', 'module_type', 'url'] + brief_fields = ['description', 'device', 'display', 'id', 'module_bay', 'module_type', 'url'] bulk_update_data = { 'serial': '1234ABCD', } @@ -1350,7 +1353,7 @@ class ModuleTest(APIViewTestCases.APIViewTestCase): class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsolePort - brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1392,7 +1395,7 @@ class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsoleServerPort - brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1434,7 +1437,7 @@ class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIView class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = PowerPort - brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1473,7 +1476,7 @@ class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = PowerOutlet - brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1521,7 +1524,7 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = Interface - brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1655,7 +1658,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase class FrontPortTest(APIViewTestCases.APIViewTestCase): model = FrontPort - brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1713,7 +1716,7 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase): class RearPortTest(APIViewTestCases.APIViewTestCase): model = RearPort - brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1755,7 +1758,7 @@ class RearPortTest(APIViewTestCases.APIViewTestCase): class ModuleBayTest(APIViewTestCases.APIViewTestCase): model = ModuleBay - brief_fields = ['display', 'id', 'installed_module', 'name', 'url'] + brief_fields = ['description', 'display', 'id', 'installed_module', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1794,7 +1797,7 @@ class ModuleBayTest(APIViewTestCases.APIViewTestCase): class DeviceBayTest(APIViewTestCases.APIViewTestCase): model = DeviceBay - brief_fields = ['device', 'display', 'id', 'name', 'url'] + brief_fields = ['description', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1857,7 +1860,7 @@ class DeviceBayTest(APIViewTestCases.APIViewTestCase): class InventoryItemTest(APIViewTestCases.APIViewTestCase): model = InventoryItem - brief_fields = ['_depth', 'device', 'display', 'id', 'name', 'url'] + brief_fields = ['_depth', 'description', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1917,7 +1920,7 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase): class InventoryItemRoleTest(APIViewTestCases.APIViewTestCase): model = InventoryItemRole - brief_fields = ['display', 'id', 'inventoryitem_count', 'name', 'slug', 'url'] + brief_fields = ['description', 'display', 'id', 'inventoryitem_count', 'name', 'slug', 'url'] create_data = [ { 'name': 'Inventory Item Role 4', @@ -1952,7 +1955,7 @@ class InventoryItemRoleTest(APIViewTestCases.APIViewTestCase): class CableTest(APIViewTestCases.APIViewTestCase): model = Cable - brief_fields = ['display', 'id', 'label', 'url'] + brief_fields = ['description', 'display', 'id', 'label', 'url'] bulk_update_data = { 'length': 100, 'length_unit': 'm', @@ -2075,7 +2078,7 @@ class ConnectedDeviceTest(APITestCase): class VirtualChassisTest(APIViewTestCases.APIViewTestCase): model = VirtualChassis - brief_fields = ['display', 'id', 'master', 'member_count', 'name', 'url'] + brief_fields = ['description', 'display', 'id', 'master', 'member_count', 'name', 'url'] @classmethod def setUpTestData(cls): @@ -2106,7 +2109,7 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase): for j in range(0, 13): interfaces.append( # Interface name starts with parent device's position in VC; e.g. 1/1, 1/2, 1/3... - Interface(device=device, name=f'{i%3+1}/{j}', type=InterfaceTypeChoices.TYPE_1GE_FIXED) + Interface(device=device, name=f'{i % 3 + 1}/{j}', type=InterfaceTypeChoices.TYPE_1GE_FIXED) ) Interface.objects.bulk_create(interfaces) @@ -2156,7 +2159,7 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase): class PowerPanelTest(APIViewTestCases.APIViewTestCase): model = PowerPanel - brief_fields = ['display', 'id', 'name', 'powerfeed_count', 'url'] + brief_fields = ['description', 'display', 'id', 'name', 'powerfeed_count', 'url'] @classmethod def setUpTestData(cls): @@ -2205,7 +2208,7 @@ class PowerPanelTest(APIViewTestCases.APIViewTestCase): class PowerFeedTest(APIViewTestCases.APIViewTestCase): model = PowerFeed - brief_fields = ['_occupied', 'cable', 'display', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'description', 'display', 'id', 'name', 'url'] bulk_update_data = { 'status': 'planned', } @@ -2260,7 +2263,7 @@ class PowerFeedTest(APIViewTestCases.APIViewTestCase): class VirtualDeviceContextTest(APIViewTestCases.APIViewTestCase): model = VirtualDeviceContext - brief_fields = ['device', 'display', 'id', 'identifier', 'name', 'url'] + brief_fields = ['description', 'device', 'display', 'id', 'identifier', 'name', 'url'] bulk_update_data = { 'status': 'planned', } @@ -2307,6 +2310,6 @@ class VirtualDeviceContextTest(APIViewTestCases.APIViewTestCase): 'device': devices[1].pk, 'status': 'active', 'name': 'VDC 3', - 'identifier': 3, + # Omit identifier to test uniqueness constraint }, ] diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index a827939f7..cd7b0e6d7 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -394,6 +394,9 @@ class CablePathTestCase(TestCase): ) self.assertEqual(CablePath.objects.count(), 2) + # Test SVG generation + CableTraceSVG(interface1).render() + # Delete cable 2 cable2.delete() path1 = self.assertPathExists( @@ -450,6 +453,9 @@ class CablePathTestCase(TestCase): ) self.assertEqual(CablePath.objects.count(), 2) + # Test SVG generation + CableTraceSVG(interface1).render() + # Delete cable 2 cable2.delete() path1 = self.assertPathExists( @@ -558,6 +564,9 @@ class CablePathTestCase(TestCase): ) self.assertEqual(CablePath.objects.count(), 4) + # Test SVG generation + CableTraceSVG(interface1).render() + # Delete cable 3 cable3.delete() @@ -673,6 +682,9 @@ class CablePathTestCase(TestCase): ) self.assertEqual(CablePath.objects.count(), 4) + # Test SVG generation + CableTraceSVG(interface1).render() + # Delete cable 3 cable3.delete() @@ -804,6 +816,9 @@ class CablePathTestCase(TestCase): ) self.assertEqual(CablePath.objects.count(), 4) + # Test SVG generation + CableTraceSVG(interface1).render() + # Delete cable 3 cable3.delete() @@ -931,6 +946,9 @@ class CablePathTestCase(TestCase): ) self.assertEqual(CablePath.objects.count(), 4) + # Test SVG generation + CableTraceSVG(interface1).render() + # Delete cable 5 cable5.delete() @@ -1034,6 +1052,9 @@ class CablePathTestCase(TestCase): ) self.assertEqual(CablePath.objects.count(), 4) + # Test SVG generation + CableTraceSVG(interface1).render() + # Delete cable 3 cable3.delete() @@ -1093,6 +1114,9 @@ class CablePathTestCase(TestCase): ) self.assertEqual(CablePath.objects.count(), 3) + # Test SVG generation + CableTraceSVG(interface1).render() + # Delete cable 1 cable1.delete() @@ -1135,6 +1159,9 @@ class CablePathTestCase(TestCase): ) self.assertEqual(CablePath.objects.count(), 1) + # Test SVG generation + CableTraceSVG(interface1).render() + def test_210_interface_to_circuittermination(self): """ [IF1] --C1-- [CT1] @@ -1156,6 +1183,9 @@ class CablePathTestCase(TestCase): ) self.assertEqual(CablePath.objects.count(), 1) + # Test SVG generation + CableTraceSVG(interface1).render() + # Delete cable 1 cable1.delete() self.assertEqual(CablePath.objects.count(), 0) @@ -1212,6 +1242,9 @@ class CablePathTestCase(TestCase): ) self.assertEqual(CablePath.objects.count(), 2) + # Test SVG generation + CableTraceSVG(interface1).render() + # Delete cable 2 cable2.delete() path1 = self.assertPathExists( @@ -1277,6 +1310,9 @@ class CablePathTestCase(TestCase): ) self.assertEqual(CablePath.objects.count(), 2) + # Test SVG generation + CableTraceSVG(interface1).render() + # Delete cable 2 cable2.delete() path1 = self.assertPathExists( @@ -1314,6 +1350,9 @@ class CablePathTestCase(TestCase): ) self.assertEqual(CablePath.objects.count(), 1) + # Test SVG generation + CableTraceSVG(interface1).render() + # Delete cable 1 cable1.delete() self.assertEqual(CablePath.objects.count(), 0) @@ -1342,6 +1381,9 @@ class CablePathTestCase(TestCase): self.assertEqual(CablePath.objects.count(), 1) self.assertTrue(CablePath.objects.first().is_complete) + # Test SVG generation + CableTraceSVG(interface1).render() + # Delete cable 1 cable1.delete() self.assertEqual(CablePath.objects.count(), 0) @@ -1439,6 +1481,9 @@ class CablePathTestCase(TestCase): ) self.assertEqual(CablePath.objects.count(), 4) + # Test SVG generation + CableTraceSVG(interface1).render() + # Delete cables 3-4 cable3.delete() cable4.delete() @@ -1495,6 +1540,9 @@ class CablePathTestCase(TestCase): ) self.assertEqual(CablePath.objects.count(), 2) + # Test SVG generation + CableTraceSVG(interface1).render() + # Delete cable 2 cable2.delete() path1 = self.assertPathExists( @@ -1578,6 +1626,9 @@ class CablePathTestCase(TestCase): ) self.assertEqual(CablePath.objects.count(), 2) + # Test SVG generation + CableTraceSVG(interface1).render() + # Delete cable 2 cable2.delete() @@ -1697,6 +1748,9 @@ class CablePathTestCase(TestCase): ) self.assertEqual(CablePath.objects.count(), 4) + # Test SVG generation + CableTraceSVG(interface1).render() + # Delete cable 3 cable3.delete() @@ -1784,6 +1838,9 @@ class CablePathTestCase(TestCase): ) self.assertEqual(CablePath.objects.count(), 2) + # Test SVG generation + CableTraceSVG(interface1).render() + def test_220_interface_to_interface_duplex_via_multiple_front_and_rear_ports(self): """ [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] @@ -1877,6 +1934,9 @@ class CablePathTestCase(TestCase): ) self.assertEqual(CablePath.objects.count(), 3) + # Test SVG generation + CableTraceSVG(interface1).render() + def test_221_non_symmetric_paths(self): """ [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- -------------------------------------- [IF2] @@ -1997,6 +2057,9 @@ class CablePathTestCase(TestCase): ) self.assertEqual(CablePath.objects.count(), 3) + # Test SVG generation + CableTraceSVG(interface1).render() + def test_301_create_path_via_existing_cable(self): """ [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] @@ -2156,7 +2219,7 @@ class CablePathTestCase(TestCase): device = Device.objects.create( site=self.site, device_type=self.device.device_type, - device_role=self.device.device_role, + role=self.device.role, name='Test mid-span Device' ) interface1 = Interface.objects.create(device=self.device, name='Interface 1') diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index b255c283e..0a22f5a82 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -6,13 +6,12 @@ from dcim.choices import * from dcim.filtersets import * from dcim.models import * from ipam.models import ASN, IPAddress, RIR, VRF +from netbox.choices import ColorChoices from tenancy.models import Tenant, TenantGroup -from utilities.choices import ColorChoices from utilities.testing import ChangeLoggedFilterSetTests, create_test_device from virtualization.models import Cluster, ClusterType from wireless.choices import WirelessChannelChoices, WirelessRoleChoices - User = get_user_model() @@ -64,21 +63,32 @@ class RegionTestCase(TestCase, ChangeLoggedFilterSetTests): @classmethod def setUpTestData(cls): - regions = ( + parent_regions = ( Region(name='Region 1', slug='region-1', description='foobar1'), Region(name='Region 2', slug='region-2', description='foobar2'), Region(name='Region 3', slug='region-3', description='foobar3'), ) + for region in parent_regions: + region.save() + + regions = ( + Region(name='Region 1A', slug='region-1a', parent=parent_regions[0]), + Region(name='Region 1B', slug='region-1b', parent=parent_regions[0]), + Region(name='Region 2A', slug='region-2a', parent=parent_regions[1]), + Region(name='Region 2B', slug='region-2b', parent=parent_regions[1]), + Region(name='Region 3A', slug='region-3a', parent=parent_regions[2]), + Region(name='Region 3B', slug='region-3b', parent=parent_regions[2]), + ) for region in regions: region.save() child_regions = ( - Region(name='Region 1A', slug='region-1a', parent=regions[0]), - Region(name='Region 1B', slug='region-1b', parent=regions[0]), - Region(name='Region 2A', slug='region-2a', parent=regions[1]), - Region(name='Region 2B', slug='region-2b', parent=regions[1]), - Region(name='Region 3A', slug='region-3a', parent=regions[2]), - Region(name='Region 3B', slug='region-3b', parent=regions[2]), + Region(name='Region 1A1', slug='region-1a1', parent=regions[0]), + Region(name='Region 1B1', slug='region-1b1', parent=regions[1]), + Region(name='Region 2A1', slug='region-2a1', parent=regions[2]), + Region(name='Region 2B1', slug='region-2b1', parent=regions[3]), + Region(name='Region 3A1', slug='region-3a1', parent=regions[4]), + Region(name='Region 3B1', slug='region-3b1', parent=regions[5]), ) for region in child_regions: region.save() @@ -100,12 +110,19 @@ class RegionTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_parent(self): - parent_regions = Region.objects.filter(parent__isnull=True)[:2] - params = {'parent_id': [parent_regions[0].pk, parent_regions[1].pk]} + regions = Region.objects.filter(parent__isnull=True)[:2] + params = {'parent_id': [regions[0].pk, regions[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'parent': [parent_regions[0].slug, parent_regions[1].slug]} + params = {'parent': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_ancestor(self): + regions = Region.objects.filter(parent__isnull=True)[:2] + params = {'ancestor_id': [regions[0].pk, regions[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + params = {'ancestor': [regions[0].slug, regions[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = SiteGroup.objects.all() @@ -114,24 +131,35 @@ class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests): @classmethod def setUpTestData(cls): - sitegroups = ( + parent_groups = ( SiteGroup(name='Site Group 1', slug='site-group-1', description='foobar1'), SiteGroup(name='Site Group 2', slug='site-group-2', description='foobar2'), SiteGroup(name='Site Group 3', slug='site-group-3', description='foobar3'), ) - for sitegroup in sitegroups: - sitegroup.save() + for site_group in parent_groups: + site_group.save() - child_sitegroups = ( - SiteGroup(name='Site Group 1A', slug='site-group-1a', parent=sitegroups[0]), - SiteGroup(name='Site Group 1B', slug='site-group-1b', parent=sitegroups[0]), - SiteGroup(name='Site Group 2A', slug='site-group-2a', parent=sitegroups[1]), - SiteGroup(name='Site Group 2B', slug='site-group-2b', parent=sitegroups[1]), - SiteGroup(name='Site Group 3A', slug='site-group-3a', parent=sitegroups[2]), - SiteGroup(name='Site Group 3B', slug='site-group-3b', parent=sitegroups[2]), + groups = ( + SiteGroup(name='Site Group 1A', slug='site-group-1a', parent=parent_groups[0]), + SiteGroup(name='Site Group 1B', slug='site-group-1b', parent=parent_groups[0]), + SiteGroup(name='Site Group 2A', slug='site-group-2a', parent=parent_groups[1]), + SiteGroup(name='Site Group 2B', slug='site-group-2b', parent=parent_groups[1]), + SiteGroup(name='Site Group 3A', slug='site-group-3a', parent=parent_groups[2]), + SiteGroup(name='Site Group 3B', slug='site-group-3b', parent=parent_groups[2]), ) - for sitegroup in child_sitegroups: - sitegroup.save() + for site_group in groups: + site_group.save() + + child_groups = ( + SiteGroup(name='Site Group 1A1', slug='site-group-1a1', parent=groups[0]), + SiteGroup(name='Site Group 1B1', slug='site-group-1b1', parent=groups[1]), + SiteGroup(name='Site Group 2A1', slug='site-group-2a1', parent=groups[2]), + SiteGroup(name='Site Group 2B1', slug='site-group-2b1', parent=groups[3]), + SiteGroup(name='Site Group 3A1', slug='site-group-3a1', parent=groups[4]), + SiteGroup(name='Site Group 3B1', slug='site-group-3b1', parent=groups[5]), + ) + for site_group in child_groups: + site_group.save() def test_q(self): params = {'q': 'foobar1'} @@ -150,16 +178,24 @@ class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_parent(self): - parent_sitegroups = SiteGroup.objects.filter(parent__isnull=True)[:2] - params = {'parent_id': [parent_sitegroups[0].pk, parent_sitegroups[1].pk]} + site_groups = SiteGroup.objects.filter(parent__isnull=True)[:2] + params = {'parent_id': [site_groups[0].pk, site_groups[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'parent': [parent_sitegroups[0].slug, parent_sitegroups[1].slug]} + params = {'parent': [site_groups[0].slug, site_groups[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_ancestor(self): + site_groups = SiteGroup.objects.filter(parent__isnull=True)[:2] + params = {'ancestor_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + params = {'ancestor': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + class SiteTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Site.objects.all() filterset = SiteFilterSet + ignore_fields = ('physical_address', 'shipping_address') @classmethod def setUpTestData(cls): @@ -314,21 +350,29 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): Site.objects.bulk_create(sites) parent_locations = ( - Location(name='Parent Location 1', slug='parent-location-1', site=sites[0]), - Location(name='Parent Location 2', slug='parent-location-2', site=sites[1]), - Location(name='Parent Location 3', slug='parent-location-3', site=sites[2]), + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[1]), + Location(name='Location 3', slug='location-3', site=sites[2]), ) for location in parent_locations: location.save() locations = ( - Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], status=LocationStatusChoices.STATUS_PLANNED, description='foobar1'), - Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], status=LocationStatusChoices.STATUS_STAGING, description='foobar2'), - Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], status=LocationStatusChoices.STATUS_DECOMMISSIONING, description='foobar3'), + Location(name='Location 1A', slug='location-1a', site=sites[0], parent=parent_locations[0], status=LocationStatusChoices.STATUS_PLANNED, facility='Facility 1', description='foobar1'), + Location(name='Location 2A', slug='location-2a', site=sites[1], parent=parent_locations[1], status=LocationStatusChoices.STATUS_STAGING, facility='Facility 2', description='foobar2'), + Location(name='Location 3A', slug='location-3a', site=sites[2], parent=parent_locations[2], status=LocationStatusChoices.STATUS_DECOMMISSIONING, facility='Facility 3', description='foobar3'), ) for location in locations: location.save() + child_locations = ( + Location(name='Location 1A1', slug='location-1a1', site=sites[0], parent=locations[0]), + Location(name='Location 2A1', slug='location-2a1', site=sites[1], parent=locations[1]), + Location(name='Location 3A1', slug='location-3a1', site=sites[2], parent=locations[2]), + ) + for location in child_locations: + location.save() + def test_q(self): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -345,6 +389,10 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'status': [LocationStatusChoices.STATUS_PLANNED, LocationStatusChoices.STATUS_STAGING]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_facility(self): + params = {'facility': ['Facility 1', 'Facility 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -352,31 +400,38 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): def test_region(self): regions = Region.objects.all()[:2] params = {'region_id': [regions[0].pk, regions[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) params = {'region': [regions[0].slug, regions[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) def test_site_group(self): site_groups = SiteGroup.objects.all()[:2] params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) params = {'site': [sites[0].slug, sites[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) def test_parent(self): - parent_groups = Location.objects.filter(name__startswith='Parent')[:2] - params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]} + locations = Location.objects.filter(parent__isnull=True)[:2] + params = {'parent_id': [locations[0].pk, locations[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]} + params = {'parent': [locations[0].slug, locations[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_ancestor(self): + locations = Location.objects.filter(parent__isnull=True)[:2] + params = {'ancestor_id': [locations[0].pk, locations[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'ancestor': [locations[0].slug, locations[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = RackRole.objects.all() @@ -416,6 +471,7 @@ class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests): class RackTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Rack.objects.all() filterset = RackFilterSet + ignore_fields = ('units',) @classmethod def setUpTestData(cls): @@ -675,6 +731,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = RackReservation.objects.all() filterset = RackReservationFilterSet + ignore_fields = ('units',) @classmethod def setUpTestData(cls): @@ -838,6 +895,7 @@ class ManufacturerTestCase(TestCase, ChangeLoggedFilterSetTests): class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = DeviceType.objects.all() filterset = DeviceTypeFilterSet + ignore_fields = ('front_image', 'rear_image') @classmethod def setUpTestData(cls): @@ -1829,6 +1887,7 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests): class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Device.objects.all() filterset = DeviceFilterSet + ignore_fields = ('local_context_data', 'oob_ip', 'primary_ip4', 'primary_ip6', 'vc_master_for') @classmethod def setUpTestData(cls): @@ -2044,6 +2103,9 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1) Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2) + # VirtualDeviceContext assignment for filtering + VirtualDeviceContext.objects.create(device=devices[0], name="VDC 1", identifier=1, status='active') + def test_q(self): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -2277,10 +2339,17 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_has_virtual_device_context(self): + params = {'has_virtual_device_context': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'has_virtual_device_context': 'false'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Module.objects.all() filterset = ModuleFilterSet + ignore_fields = ('local_context_data',) @classmethod def setUpTestData(cls): @@ -3178,6 +3247,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = Interface.objects.all() filterset = InterfaceFilterSet + ignore_fields = ('tagged_vlans', 'untagged_vlan', 'vdcs') @classmethod def setUpTestData(cls): @@ -5281,6 +5351,7 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests): class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VirtualDeviceContext.objects.all() filterset = VirtualDeviceContextFilterSet + ignore_fields = ('primary_ip4', 'primary_ip6') @classmethod def setUpTestData(cls): @@ -5350,15 +5421,22 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests): VirtualDeviceContext.objects.bulk_create(vdcs) interfaces = ( - Interface(device=devices[0], name='Interface 1', type='virtual'), - Interface(device=devices[0], name='Interface 2', type='virtual'), + Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL), + Interface(device=devices[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_VIRTUAL), + Interface(device=devices[1], name='Interface 3', type=InterfaceTypeChoices.TYPE_VIRTUAL), + Interface(device=devices[1], name='Interface 4', type=InterfaceTypeChoices.TYPE_VIRTUAL), + Interface(device=devices[2], name='Interface 5', type=InterfaceTypeChoices.TYPE_VIRTUAL), + Interface(device=devices[2], name='Interface 6', type=InterfaceTypeChoices.TYPE_VIRTUAL), ) Interface.objects.bulk_create(interfaces) - interfaces[0].vdcs.set([vdcs[0]]) interfaces[1].vdcs.set([vdcs[1]]) + interfaces[2].vdcs.set([vdcs[2]]) + interfaces[3].vdcs.set([vdcs[3]]) + interfaces[4].vdcs.set([vdcs[4]]) + interfaces[5].vdcs.set([vdcs[5]]) - addresses = ( + ip_addresses = ( IPAddress(assigned_object=interfaces[0], address='10.1.1.1/24'), IPAddress(assigned_object=interfaces[1], address='10.1.1.2/24'), IPAddress(assigned_object=None, address='10.1.1.3/24'), @@ -5366,13 +5444,12 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests): IPAddress(assigned_object=interfaces[1], address='2001:db8::2/64'), IPAddress(assigned_object=None, address='2001:db8::3/64'), ) - IPAddress.objects.bulk_create(addresses) - - vdcs[0].primary_ip4 = addresses[0] - vdcs[0].primary_ip6 = addresses[3] + IPAddress.objects.bulk_create(ip_addresses) + vdcs[0].primary_ip4 = ip_addresses[0] + vdcs[0].primary_ip6 = ip_addresses[3] vdcs[0].save() - vdcs[1].primary_ip4 = addresses[1] - vdcs[1].primary_ip6 = addresses[4] + vdcs[1].primary_ip4 = ip_addresses[1] + vdcs[1].primary_ip6 = ip_addresses[4] vdcs[1].save() def test_q(self): @@ -5380,8 +5457,11 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_device(self): - params = {'device': ['Device 1', 'Device 2']} + devices = Device.objects.filter(name__in=['Device 1', 'Device 2']) + params = {'device': [devices[0].name, devices[1].name]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'device_id': [devices[0].pk, devices[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_status(self): params = {'status': ['active']} @@ -5391,10 +5471,10 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_device_id(self): - devices = Device.objects.filter(name__in=['Device 1', 'Device 2']) - params = {'device_id': [devices[0].pk, devices[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_interface(self): + interfaces = Interface.objects.filter(name__in=['Interface 1', 'Interface 3']) + params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_has_primary_ip(self): params = {'has_primary_ip': True} diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index d56bf0741..cab1760ed 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -1,13 +1,13 @@ -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.test import TestCase from circuits.models import * +from core.models import ObjectType from dcim.choices import * from dcim.models import * from extras.models import CustomField from tenancy.models import Tenant -from utilities.utils import drange +from utilities.data import drange class LocationTestCase(TestCase): @@ -293,8 +293,8 @@ class DeviceTestCase(TestCase): # Create a CustomField with a default value & assign it to all component models cf1 = CustomField.objects.create(name='cf1', default='foo') - cf1.content_types.set( - ContentType.objects.filter(app_label='dcim', model__in=[ + cf1.object_types.set( + ObjectType.objects.filter(app_label='dcim', model__in=[ 'consoleport', 'consoleserverport', 'powerport', @@ -533,30 +533,6 @@ class DeviceTestCase(TestCase): device2.full_clean() device2.save() - def test_old_device_role_field(self): - """ - Ensure that the old device role field sets the value in the new role field. - """ - - # Test getter method - device = Device( - site=Site.objects.first(), - device_type=DeviceType.objects.first(), - role=DeviceRole.objects.first(), - name='Test Device 1', - device_role=DeviceRole.objects.first() - ) - device.full_clean() - device.save() - - self.assertEqual(device.role, device.device_role) - - # Test setter method - device.device_role = DeviceRole.objects.last() - device.full_clean() - device.save() - self.assertEqual(device.role, device.device_role) - class CableTestCase(TestCase): diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 88e0d44f2..ec85fc1d5 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1,13 +1,8 @@ from decimal import Decimal -try: - from zoneinfo import ZoneInfo -except ImportError: - # Python 3.8 - from backports.zoneinfo import ZoneInfo +from zoneinfo import ZoneInfo import yaml from django.contrib.auth import get_user_model -from django.contrib.contenttypes.models import ContentType from django.test import override_settings from django.urls import reverse from netaddr import EUI @@ -16,12 +11,11 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from ipam.models import ASN, RIR, VLAN, VRF +from netbox.choices import CSVDelimiterChoices, ImportFormatChoices from tenancy.models import Tenant -from utilities.choices import CSVDelimiterChoices, ImportFormatChoices from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data from wireless.models import WirelessLAN - User = get_user_model() @@ -218,6 +212,7 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'slug': 'location-x', 'site': site.pk, 'status': LocationStatusChoices.STATUS_PLANNED, + 'facility': 'Facility X', 'tenant': tenant.pk, 'description': 'A new location', 'tags': [t.pk for t in tags], @@ -2986,7 +2981,6 @@ class CableTestCase( tags = create_tags('Alpha', 'Bravo', 'Charlie') - interface_ct = ContentType.objects.get_for_model(Interface) cls.form_data = { # TODO: Revisit this limitation # Changing terminations not supported when editing an existing Cable diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 2a2fe39e3..670995231 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -25,10 +25,12 @@ from tenancy.views import ObjectContactsView from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.permissions import get_permission_for_model +from utilities.query import count_related from utilities.query_functions import CollateAsChar -from utilities.utils import count_related from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view +from virtualization.filtersets import VirtualMachineFilterSet from virtualization.models import VirtualMachine +from virtualization.tables import VirtualMachineTable from . import filtersets, forms, tables from .choices import DeviceFaceChoices from .models import * @@ -727,7 +729,6 @@ class RackNonRackedView(generic.ObjectChildrenView): class RackEditView(generic.ObjectEditView): queryset = Rack.objects.all() form = forms.RackForm - template_name = 'dcim/rack_edit.html' @register_model_view(Rack, 'delete') @@ -1079,7 +1080,7 @@ class DeviceTypeInventoryItemsView(DeviceTypeComponentsView): tab = ViewTab( label=_('Inventory Items'), badge=lambda obj: obj.inventory_item_template_count, - permission='dcim.view_invenotryitemtemplate', + permission='dcim.view_inventoryitemtemplate', weight=590, hide_if_empty=True ) @@ -2086,6 +2087,24 @@ class DeviceRenderConfigView(generic.ObjectView): } +@register_model_view(Device, 'virtual-machines') +class DeviceVirtualMachinesView(generic.ObjectChildrenView): + queryset = Device.objects.all() + child_model = VirtualMachine + table = VirtualMachineTable + filterset = VirtualMachineFilterSet + tab = ViewTab( + label=_('Virtual Machines'), + badge=lambda obj: VirtualMachine.objects.filter(cluster=obj.cluster, device=obj).count(), + weight=2200, + hide_if_empty=True, + permission='virtualization.view_virtualmachine' + ) + + def get_children(self, request, parent): + return self.child_model.objects.restrict(request.user, 'view').filter(cluster=parent.cluster, device=parent) + + class DeviceBulkImportView(generic.BulkImportView): queryset = Device.objects.all() model_form = forms.DeviceImportForm @@ -2925,14 +2944,12 @@ class InventoryItemView(generic.ObjectView): class InventoryItemEditView(generic.ObjectEditView): queryset = InventoryItem.objects.all() form = forms.InventoryItemForm - template_name = 'dcim/inventoryitem_edit.html' class InventoryItemCreateView(generic.ComponentCreateView): queryset = InventoryItem.objects.all() form = forms.InventoryItemCreateForm model_form = forms.InventoryItemForm - template_name = 'dcim/inventoryitem_edit.html' @register_model_view(InventoryItem, 'delete') @@ -2960,7 +2977,6 @@ class InventoryItemBulkDeleteView(generic.BulkDeleteView): queryset = InventoryItem.objects.all() filterset = filtersets.InventoryItemFilterSet table = tables.InventoryItemTable - template_name = 'dcim/inventoryitem_bulk_delete.html' @register_model_view(InventoryItem, 'children') @@ -2969,7 +2985,6 @@ class InventoryItemChildrenView(generic.ObjectChildrenView): child_model = InventoryItem table = tables.InventoryItemTable filterset = filtersets.InventoryItemFilterSet - template_name = 'generic/object_children.html' tab = ViewTab( label=_('Children'), badge=lambda obj: obj.child_items.count(), @@ -3164,12 +3179,6 @@ class CableListView(generic.ObjectListView): filterset = filtersets.CableFilterSet filterset_form = forms.CableFilterForm table = tables.CableTable - actions = { - 'import': {'add'}, - 'export': {'view'}, - 'bulk_edit': {'change'}, - 'bulk_delete': {'delete'}, - } @register_model_view(Cable) @@ -3181,34 +3190,29 @@ class CableView(generic.ObjectView): class CableEditView(generic.ObjectEditView): queryset = Cable.objects.all() template_name = 'dcim/cable_edit.html' + htmx_template_name = 'dcim/htmx/cable_edit.html' - def dispatch(self, request, *args, **kwargs): - - # If creating a new Cable, initialize the form class using URL query params - if 'pk' not in kwargs: - self.form = forms.get_cable_form( - a_type=CABLE_TERMINATION_TYPES.get(request.GET.get('a_terminations_type')), - b_type=CABLE_TERMINATION_TYPES.get(request.GET.get('b_terminations_type')) - ) - - return super().dispatch(request, *args, **kwargs) - - def get_object(self, **kwargs): + def alter_object(self, obj, request, url_args, url_kwargs): """ - Hack into get_object() to set the form class when editing an existing Cable, since ObjectEditView + Hack into alter_object() to set the form class when editing an existing Cable, since ObjectEditView doesn't currently provide a hook for dynamic class resolution. """ - obj = super().get_object(**kwargs) + a_terminations_type = CABLE_TERMINATION_TYPES.get( + request.GET.get('a_terminations_type') or request.POST.get('a_terminations_type') + ) + b_terminations_type = CABLE_TERMINATION_TYPES.get( + request.GET.get('b_terminations_type') or request.POST.get('b_terminations_type') + ) if obj.pk: - # TODO: Optimize this logic - termination_a = obj.terminations.filter(cable_end='A').first() - a_type = termination_a.termination._meta.model if termination_a else None - termination_b = obj.terminations.filter(cable_end='B').first() - b_type = termination_b.termination._meta.model if termination_b else None - self.form = forms.get_cable_form(a_type, b_type) + if not a_terminations_type and (termination_a := obj.terminations.filter(cable_end='A').first()): + a_terminations_type = termination_a.termination._meta.model + if not b_terminations_type and (termination_b := obj.terminations.filter(cable_end='B').first()): + b_terminations_type = termination_b.termination._meta.model - return obj + self.form = forms.get_cable_form(a_terminations_type, b_terminations_type) + + return super().alter_object(obj, request, url_args, url_kwargs) def get_extra_addanother_params(self, request): @@ -3357,6 +3361,7 @@ class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, V formset = VCMemberFormSet(queryset=members_queryset) return render(request, 'dcim/virtualchassis_edit.html', { + 'object': virtual_chassis, 'vc_form': vc_form, 'formset': formset, 'return_url': self.get_return_url(request, virtual_chassis), diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index 6cd3a245e..09f247929 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -1,13 +1,12 @@ -from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ -from drf_spectacular.utils import extend_schema_field from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field from rest_framework.fields import Field from rest_framework.serializers import ValidationError +from core.models import ObjectType from extras.choices import CustomFieldTypeChoices from extras.models import CustomField -from netbox.constants import NESTED_SERIALIZER_PREFIX from utilities.api import get_serializer_for_model @@ -25,8 +24,8 @@ class CustomFieldDefaultValues: self.model = serializer_field.parent.Meta.model # Retrieve the CustomFields for the parent model - content_type = ContentType.objects.get_for_model(self.model) - fields = CustomField.objects.filter(content_types=content_type) + object_type = ObjectType.objects.get_for_model(self.model) + fields = CustomField.objects.filter(object_types=object_type) # Populate the default value for each CustomField value = {} @@ -47,8 +46,8 @@ class CustomFieldsDataField(Field): Cache CustomFields assigned to this model to avoid redundant database queries """ if not hasattr(self, '_custom_fields'): - content_type = ContentType.objects.get_for_model(self.parent.Meta.model) - self._custom_fields = CustomField.objects.filter(content_types=content_type) + object_type = ObjectType.objects.get_for_model(self.parent.Meta.model) + self._custom_fields = CustomField.objects.filter(object_types=object_type) return self._custom_fields def to_representation(self, obj): @@ -58,11 +57,11 @@ class CustomFieldsDataField(Field): for cf in self._get_custom_fields(): value = cf.deserialize(obj.get(cf.name)) if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT: - serializer = get_serializer_for_model(cf.object_type.model_class(), prefix=NESTED_SERIALIZER_PREFIX) - value = serializer(value, context=self.parent.context).data + serializer = get_serializer_for_model(cf.related_object_type.model_class()) + value = serializer(value, nested=True, context=self.parent.context).data elif value is not None and cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: - serializer = get_serializer_for_model(cf.object_type.model_class(), prefix=NESTED_SERIALIZER_PREFIX) - value = serializer(value, many=True, context=self.parent.context).data + serializer = get_serializer_for_model(cf.related_object_type.model_class()) + value = serializer(value, nested=True, many=True, context=self.parent.context).data data[cf.name] = value return data @@ -80,12 +79,9 @@ class CustomFieldsDataField(Field): CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT ): - serializer_class = get_serializer_for_model( - model=cf.object_type.model_class(), - prefix=NESTED_SERIALIZER_PREFIX - ) + serializer_class = get_serializer_for_model(cf.related_object_type.model_class()) many = cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT - serializer = serializer_class(data=data[cf.name], many=many, context=self.parent.context) + serializer = serializer_class(data=data[cf.name], nested=True, many=many, context=self.parent.context) if serializer.is_valid(): data[cf.name] = [obj['id'] for obj in serializer.data] if many else serializer.data['id'] else: diff --git a/netbox/extras/api/mixins.py b/netbox/extras/api/mixins.py index 65a260f6f..d476287db 100644 --- a/netbox/extras/api/mixins.py +++ b/netbox/extras/api/mixins.py @@ -7,7 +7,7 @@ from rest_framework.status import HTTP_400_BAD_REQUEST from dcim.models import Device from netbox.api.authentication import ViewOnlyPermissions from netbox.api.renderers import TextRenderer -from .nested_serializers import NestedConfigTemplateSerializer +from .serializers import ConfigTemplateSerializer __all__ = ( 'ConfigContextQuerySetMixin', @@ -54,7 +54,7 @@ class ConfigTemplateRenderMixin: if request.accepted_renderer.format == 'txt': return Response(output) - template_serializer = NestedConfigTemplateSerializer(configtemplate, context={'request': request}) + template_serializer = ConfigTemplateSerializer(configtemplate, nested=True, context={'request': request}) return Response({ 'configtemplate': template_serializer.data, diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 8f00e11d9..bd19b3184 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,690 +1,16 @@ -from django.contrib.auth import get_user_model -from django.core.exceptions import ObjectDoesNotExist -from django.utils.translation import gettext as _ -from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import extend_schema_field -from rest_framework import serializers -from rest_framework.fields import ListField - -from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer, NestedJobSerializer -from core.api.serializers import JobSerializer -from core.models import ContentType -from dcim.api.nested_serializers import ( - NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer, - NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer, -) -from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup -from extras.choices import * -from extras.models import * -from netbox.api.exceptions import SerializerNotFound -from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField -from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer -from netbox.api.serializers.features import TaggableModelSerializer -from netbox.constants import NESTED_SERIALIZER_PREFIX -from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer -from tenancy.models import Tenant, TenantGroup -from users.api.nested_serializers import NestedUserSerializer -from utilities.api import get_serializer_for_model -from virtualization.api.nested_serializers import ( - NestedClusterGroupSerializer, NestedClusterSerializer, NestedClusterTypeSerializer, -) -from virtualization.models import Cluster, ClusterGroup, ClusterType +from .serializers_.objecttypes import * +from .serializers_.attachments import * +from .serializers_.bookmarks import * +from .serializers_.change_logging import * +from .serializers_.customfields import * +from .serializers_.customlinks import * +from .serializers_.dashboard import * +from .serializers_.events import * +from .serializers_.exporttemplates import * +from .serializers_.journaling import * +from .serializers_.configcontexts import * +from .serializers_.configtemplates import * +from .serializers_.savedfilters import * +from .serializers_.scripts import * +from .serializers_.tags import * from .nested_serializers import * - -__all__ = ( - 'BookmarkSerializer', - 'ConfigContextSerializer', - 'ConfigTemplateSerializer', - 'ContentTypeSerializer', - 'CustomFieldChoiceSetSerializer', - 'CustomFieldSerializer', - 'CustomLinkSerializer', - 'DashboardSerializer', - 'EventRuleSerializer', - 'ExportTemplateSerializer', - 'ImageAttachmentSerializer', - 'JournalEntrySerializer', - 'ObjectChangeSerializer', - 'ReportDetailSerializer', - 'ReportSerializer', - 'ReportInputSerializer', - 'SavedFilterSerializer', - 'ScriptDetailSerializer', - 'ScriptInputSerializer', - 'ScriptLogMessageSerializer', - 'ScriptOutputSerializer', - 'ScriptSerializer', - 'TagSerializer', - 'WebhookSerializer', -) - - -# -# Event Rules -# - -class EventRuleSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:eventrule-detail') - content_types = ContentTypeField( - queryset=ContentType.objects.with_feature('event_rules'), - many=True - ) - action_type = ChoiceField(choices=EventRuleActionChoices) - action_object_type = ContentTypeField( - queryset=ContentType.objects.with_feature('event_rules'), - ) - action_object = serializers.SerializerMethodField(read_only=True) - - class Meta: - model = EventRule - fields = [ - 'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', - 'type_job_start', 'type_job_end', 'enabled', 'conditions', 'action_type', 'action_object_type', - 'action_object_id', 'action_object', 'description', 'custom_fields', 'tags', 'created', 'last_updated', - ] - - @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_name = instance.action_parameters['script_name'] - script = instance.action_object.scripts[script_name]() - return NestedScriptSerializer(script, context=context).data - else: - serializer = get_serializer_for_model( - model=instance.action_object_type.model_class(), - prefix=NESTED_SERIALIZER_PREFIX - ) - return serializer(instance.action_object, context=context).data - - -# -# Webhooks -# - -class WebhookSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail') - - class Meta: - model = Webhook - fields = [ - 'id', 'url', 'display', 'name', 'description', 'payload_url', 'http_method', 'http_content_type', - 'additional_headers', 'body_template', 'secret', 'ssl_verification', 'ca_file_path', 'custom_fields', - 'tags', 'created', 'last_updated', - ] - - -# -# Custom fields -# - -class CustomFieldSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail') - content_types = ContentTypeField( - queryset=ContentType.objects.with_feature('custom_fields'), - many=True - ) - type = ChoiceField(choices=CustomFieldTypeChoices) - object_type = ContentTypeField( - queryset=ContentType.objects.all(), - required=False, - allow_null=True - ) - filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False) - data_type = serializers.SerializerMethodField() - choice_set = NestedCustomFieldChoiceSetSerializer( - required=False, - allow_null=True - ) - ui_visible = ChoiceField(choices=CustomFieldUIVisibleChoices, required=False) - ui_editable = ChoiceField(choices=CustomFieldUIEditableChoices, required=False) - - class Meta: - model = CustomField - fields = [ - 'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name', - 'description', 'required', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'is_cloneable', - 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set', - 'created', 'last_updated', - ] - - def validate_type(self, value): - if self.instance and self.instance.type != value: - raise serializers.ValidationError(_('Changing the type of custom fields is not supported.')) - - return value - - @extend_schema_field(OpenApiTypes.STR) - def get_data_type(self, obj): - types = CustomFieldTypeChoices - if obj.type == types.TYPE_INTEGER: - return 'integer' - if obj.type == types.TYPE_DECIMAL: - return 'decimal' - if obj.type == types.TYPE_BOOLEAN: - return 'boolean' - if obj.type in (types.TYPE_JSON, types.TYPE_OBJECT): - return 'object' - if obj.type in (types.TYPE_MULTISELECT, types.TYPE_MULTIOBJECT): - return 'array' - return 'string' - - -class CustomFieldChoiceSetSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfieldchoiceset-detail') - base_choices = ChoiceField( - choices=CustomFieldChoiceSetBaseChoices, - required=False - ) - extra_choices = serializers.ListField( - child=serializers.ListField( - min_length=2, - max_length=2 - ) - ) - - class Meta: - model = CustomFieldChoiceSet - fields = [ - 'id', 'url', 'display', 'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically', - 'choices_count', 'created', 'last_updated', - ] - - -# -# Custom links -# - -class CustomLinkSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail') - content_types = ContentTypeField( - queryset=ContentType.objects.with_feature('custom_links'), - many=True - ) - - class Meta: - model = CustomLink - fields = [ - 'id', 'url', 'display', 'content_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', - 'button_class', 'new_window', 'created', 'last_updated', - ] - - -# -# Export templates -# - -class ExportTemplateSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail') - content_types = ContentTypeField( - queryset=ContentType.objects.with_feature('export_templates'), - many=True - ) - data_source = NestedDataSourceSerializer( - required=False - ) - data_file = NestedDataFileSerializer( - read_only=True - ) - - class Meta: - model = ExportTemplate - fields = [ - 'id', 'url', 'display', 'content_types', 'name', 'description', 'template_code', 'mime_type', - 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created', - 'last_updated', - ] - - -# -# Saved filters -# - -class SavedFilterSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:savedfilter-detail') - content_types = ContentTypeField( - queryset=ContentType.objects.all(), - many=True - ) - - class Meta: - model = SavedFilter - fields = [ - 'id', 'url', 'display', 'content_types', 'name', 'slug', 'description', 'user', 'weight', 'enabled', - 'shared', 'parameters', 'created', 'last_updated', - ] - - -# -# Bookmarks -# - -class BookmarkSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail') - object_type = ContentTypeField( - queryset=ContentType.objects.with_feature('bookmarks'), - ) - object = serializers.SerializerMethodField(read_only=True) - user = NestedUserSerializer() - - class Meta: - model = Bookmark - fields = [ - 'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created', - ] - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_object(self, instance): - serializer = get_serializer_for_model(instance.object, prefix=NESTED_SERIALIZER_PREFIX) - return serializer(instance.object, context={'request': self.context['request']}).data - - -# -# Tags -# - -class TagSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail') - object_types = ContentTypeField( - queryset=ContentType.objects.with_feature('tags'), - many=True, - required=False - ) - tagged_items = serializers.IntegerField(read_only=True) - - class Meta: - model = Tag - fields = [ - 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'object_types', 'tagged_items', 'created', - 'last_updated', - ] - - -# -# Image attachments -# - -class ImageAttachmentSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail') - content_type = ContentTypeField( - queryset=ContentType.objects.all() - ) - parent = serializers.SerializerMethodField(read_only=True) - - class Meta: - model = ImageAttachment - fields = [ - 'id', 'url', 'display', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height', - 'image_width', 'created', 'last_updated', - ] - - def validate(self, data): - - # Validate that the parent object exists - try: - data['content_type'].get_object_for_this_type(id=data['object_id']) - except ObjectDoesNotExist: - raise serializers.ValidationError( - "Invalid parent object: {} ID {}".format(data['content_type'], data['object_id']) - ) - - # Enforce model validation - 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, prefix=NESTED_SERIALIZER_PREFIX) - return serializer(obj.parent, context={'request': self.context['request']}).data - - -# -# Journal entries -# - -class JournalEntrySerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:journalentry-detail') - assigned_object_type = ContentTypeField( - queryset=ContentType.objects.all() - ) - assigned_object = serializers.SerializerMethodField(read_only=True) - created_by = serializers.PrimaryKeyRelatedField( - allow_null=True, - queryset=get_user_model().objects.all(), - required=False, - default=serializers.CurrentUserDefault() - ) - kind = ChoiceField( - choices=JournalEntryKindChoices, - required=False - ) - - class Meta: - model = JournalEntry - fields = [ - 'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created', - 'created_by', 'kind', 'comments', 'tags', 'custom_fields', 'last_updated', - ] - - def validate(self, data): - - # Validate that the parent object exists - if 'assigned_object_type' in data and 'assigned_object_id' in data: - try: - data['assigned_object_type'].get_object_for_this_type(id=data['assigned_object_id']) - except ObjectDoesNotExist: - raise serializers.ValidationError( - f"Invalid assigned_object: {data['assigned_object_type']} ID {data['assigned_object_id']}" - ) - - # Enforce model validation - super().validate(data) - - return data - - @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(), prefix=NESTED_SERIALIZER_PREFIX) - context = {'request': self.context['request']} - return serializer(instance.assigned_object, context=context).data - - -# -# Config contexts -# - -class ConfigContextSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:configcontext-detail') - regions = SerializedPKRelatedField( - queryset=Region.objects.all(), - serializer=NestedRegionSerializer, - required=False, - many=True - ) - site_groups = SerializedPKRelatedField( - queryset=SiteGroup.objects.all(), - serializer=NestedSiteGroupSerializer, - required=False, - many=True - ) - sites = SerializedPKRelatedField( - queryset=Site.objects.all(), - serializer=NestedSiteSerializer, - required=False, - many=True - ) - locations = SerializedPKRelatedField( - queryset=Location.objects.all(), - serializer=NestedLocationSerializer, - required=False, - many=True - ) - device_types = SerializedPKRelatedField( - queryset=DeviceType.objects.all(), - serializer=NestedDeviceTypeSerializer, - required=False, - many=True - ) - roles = SerializedPKRelatedField( - queryset=DeviceRole.objects.all(), - serializer=NestedDeviceRoleSerializer, - required=False, - many=True - ) - platforms = SerializedPKRelatedField( - queryset=Platform.objects.all(), - serializer=NestedPlatformSerializer, - required=False, - many=True - ) - cluster_types = SerializedPKRelatedField( - queryset=ClusterType.objects.all(), - serializer=NestedClusterTypeSerializer, - required=False, - many=True - ) - cluster_groups = SerializedPKRelatedField( - queryset=ClusterGroup.objects.all(), - serializer=NestedClusterGroupSerializer, - required=False, - many=True - ) - clusters = SerializedPKRelatedField( - queryset=Cluster.objects.all(), - serializer=NestedClusterSerializer, - required=False, - many=True - ) - tenant_groups = SerializedPKRelatedField( - queryset=TenantGroup.objects.all(), - serializer=NestedTenantGroupSerializer, - required=False, - many=True - ) - tenants = SerializedPKRelatedField( - queryset=Tenant.objects.all(), - serializer=NestedTenantSerializer, - required=False, - many=True - ) - tags = serializers.SlugRelatedField( - queryset=Tag.objects.all(), - slug_field='slug', - required=False, - many=True - ) - data_source = NestedDataSourceSerializer( - required=False - ) - data_file = NestedDataFileSerializer( - read_only=True - ) - - class Meta: - model = ConfigContext - fields = [ - 'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', - 'locations', 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', - 'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file', 'data_synced', 'data', - 'created', 'last_updated', - ] - - -# -# Config templates -# - -class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:configtemplate-detail') - data_source = NestedDataSourceSerializer( - required=False - ) - data_file = NestedDataFileSerializer( - required=False - ) - - class Meta: - model = ConfigTemplate - fields = [ - 'id', 'url', 'display', 'name', 'description', 'environment_params', 'template_code', 'data_source', - 'data_path', 'data_file', 'data_synced', 'tags', 'created', 'last_updated', - ] - - -# -# Reports -# - -class ReportSerializer(serializers.Serializer): - url = serializers.HyperlinkedIdentityField( - view_name='extras-api:report-detail', - lookup_field='full_name', - lookup_url_kwarg='pk' - ) - id = serializers.CharField(read_only=True, source="full_name") - module = serializers.CharField(max_length=255) - name = serializers.CharField(max_length=255) - description = serializers.CharField(max_length=255, required=False) - test_methods = serializers.ListField(child=serializers.CharField(max_length=255), read_only=True) - result = NestedJobSerializer() - display = serializers.SerializerMethodField(read_only=True) - - @extend_schema_field(serializers.CharField()) - def get_display(self, obj): - return f'{obj.name} ({obj.module})' - - -class ReportDetailSerializer(ReportSerializer): - result = JobSerializer() - - -class ReportInputSerializer(serializers.Serializer): - schedule_at = serializers.DateTimeField(required=False, allow_null=True) - interval = serializers.IntegerField(required=False, allow_null=True) - - def validate_schedule_at(self, value): - if value and not self.context['report'].scheduling_enabled: - raise serializers.ValidationError(_("Scheduling is not enabled for this report.")) - return value - - def validate_interval(self, value): - if value and not self.context['report'].scheduling_enabled: - raise serializers.ValidationError(_("Scheduling is not enabled for this report.")) - return value - - -# -# Scripts -# - -class ScriptSerializer(serializers.Serializer): - url = serializers.HyperlinkedIdentityField( - view_name='extras-api:script-detail', - lookup_field='full_name', - lookup_url_kwarg='pk' - ) - id = serializers.CharField(read_only=True, source="full_name") - module = serializers.CharField(max_length=255) - name = serializers.CharField(read_only=True) - description = serializers.CharField(read_only=True) - vars = serializers.SerializerMethodField(read_only=True) - result = NestedJobSerializer() - display = serializers.SerializerMethodField(read_only=True) - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_vars(self, instance): - return { - k: v.__class__.__name__ for k, v in instance._get_vars().items() - } - - @extend_schema_field(serializers.CharField()) - def get_display(self, obj): - return f'{obj.name} ({obj.module})' - - -class ScriptDetailSerializer(ScriptSerializer): - result = JobSerializer() - - -class ScriptInputSerializer(serializers.Serializer): - data = serializers.JSONField() - commit = serializers.BooleanField() - schedule_at = serializers.DateTimeField(required=False, allow_null=True) - interval = serializers.IntegerField(required=False, allow_null=True) - - def validate_schedule_at(self, value): - if value and not self.context['script'].scheduling_enabled: - raise serializers.ValidationError(_("Scheduling is not enabled for this script.")) - return value - - def validate_interval(self, value): - if value and not self.context['script'].scheduling_enabled: - raise serializers.ValidationError(_("Scheduling is not enabled for this script.")) - return value - - -class ScriptLogMessageSerializer(serializers.Serializer): - status = serializers.SerializerMethodField(read_only=True) - message = serializers.SerializerMethodField(read_only=True) - - def get_status(self, instance): - return instance[0] - - def get_message(self, instance): - return instance[1] - - -class ScriptOutputSerializer(serializers.Serializer): - log = ScriptLogMessageSerializer(many=True, read_only=True) - output = serializers.CharField(read_only=True) - - -# -# Change logging -# - -class ObjectChangeSerializer(BaseModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:objectchange-detail') - user = NestedUserSerializer( - read_only=True - ) - action = ChoiceField( - choices=ObjectChangeActionChoices, - read_only=True - ) - changed_object_type = ContentTypeField( - read_only=True - ) - changed_object = serializers.SerializerMethodField( - read_only=True - ) - - class Meta: - model = ObjectChange - fields = [ - 'id', 'url', 'display', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', - 'changed_object_id', 'changed_object', '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, prefix=NESTED_SERIALIZER_PREFIX) - except SerializerNotFound: - return obj.object_repr - context = { - 'request': self.context['request'] - } - data = serializer(obj.changed_object, context=context).data - - return data - - -# -# ContentTypes -# - -class ContentTypeSerializer(BaseModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:contenttype-detail') - - class Meta: - model = ContentType - fields = ['id', 'url', 'display', 'app_label', 'model'] - - -# -# User dashboard -# - -class DashboardSerializer(serializers.ModelSerializer): - class Meta: - model = Dashboard - fields = ('layout', 'config') diff --git a/netbox/extras/api/serializers_/__init__.py b/netbox/extras/api/serializers_/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/extras/api/serializers_/attachments.py b/netbox/extras/api/serializers_/attachments.py new file mode 100644 index 000000000..bcf3a24ec --- /dev/null +++ b/netbox/extras/api/serializers_/attachments.py @@ -0,0 +1,50 @@ +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.serializers import ValidatedModelSerializer +from utilities.api import get_serializer_for_model + +__all__ = ( + 'ImageAttachmentSerializer', +) + + +class ImageAttachmentSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail') + object_type = ContentTypeField( + queryset=ObjectType.objects.all() + ) + parent = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = ImageAttachment + fields = [ + 'id', 'url', 'display', 'object_type', 'object_id', 'parent', 'name', 'image', 'image_height', + 'image_width', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'image') + + def validate(self, data): + + # Validate that the parent object exists + try: + data['object_type'].get_object_for_this_type(id=data['object_id']) + except ObjectDoesNotExist: + raise serializers.ValidationError( + "Invalid parent object: {} ID {}".format(data['object_type'], data['object_id']) + ) + + # Enforce model validation + 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 new file mode 100644 index 000000000..7a2d4d6aa --- /dev/null +++ b/netbox/extras/api/serializers_/bookmarks.py @@ -0,0 +1,35 @@ +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.serializers import ValidatedModelSerializer +from users.api.serializers_.users import UserSerializer +from utilities.api import get_serializer_for_model + +__all__ = ( + 'BookmarkSerializer', +) + + +class BookmarkSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail') + object_type = ContentTypeField( + queryset=ObjectType.objects.with_feature('bookmarks'), + ) + object = serializers.SerializerMethodField(read_only=True) + user = UserSerializer(nested=True) + + class Meta: + model = Bookmark + fields = [ + '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_/change_logging.py b/netbox/extras/api/serializers_/change_logging.py new file mode 100644 index 000000000..46fb901ff --- /dev/null +++ b/netbox/extras/api/serializers_/change_logging.py @@ -0,0 +1,65 @@ +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from extras.choices import * +from extras.models import ObjectChange +from netbox.api.exceptions import SerializerNotFound +from netbox.api.fields import ChoiceField, ContentTypeField +from netbox.api.serializers import BaseModelSerializer +from users.api.serializers_.users import UserSerializer +from utilities.api import get_serializer_for_model + +__all__ = ( + 'ObjectChangeSerializer', +) + + +class ObjectChangeSerializer(BaseModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:objectchange-detail') + user = UserSerializer( + nested=True, + read_only=True + ) + action = ChoiceField( + choices=ObjectChangeActionChoices, + read_only=True + ) + changed_object_type = ContentTypeField( + read_only=True + ) + changed_object = serializers.SerializerMethodField( + read_only=True + ) + prechange_data = serializers.JSONField( + source='prechange_data_clean', + read_only=True, + allow_null=True + ) + postchange_data = serializers.JSONField( + source='postchange_data_clean', + read_only=True, + allow_null=True + ) + + class Meta: + model = ObjectChange + fields = [ + 'id', 'url', 'display', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', + 'changed_object_id', 'changed_object', '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/extras/api/serializers_/configcontexts.py b/netbox/extras/api/serializers_/configcontexts.py new file mode 100644 index 000000000..e9688f254 --- /dev/null +++ b/netbox/extras/api/serializers_/configcontexts.py @@ -0,0 +1,131 @@ +from rest_framework import serializers + +from core.api.serializers_.data import DataFileSerializer, DataSourceSerializer +from dcim.api.serializers_.devicetypes import DeviceTypeSerializer +from dcim.api.serializers_.platforms import PlatformSerializer +from dcim.api.serializers_.roles import DeviceRoleSerializer +from dcim.api.serializers_.sites import LocationSerializer, RegionSerializer, SiteSerializer, SiteGroupSerializer +from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup +from extras.models import ConfigContext, Tag +from netbox.api.fields import SerializedPKRelatedField +from netbox.api.serializers import ValidatedModelSerializer +from tenancy.api.serializers_.tenants import TenantSerializer, TenantGroupSerializer +from tenancy.models import Tenant, TenantGroup +from virtualization.api.serializers_.clusters import ClusterSerializer, ClusterGroupSerializer, ClusterTypeSerializer +from virtualization.models import Cluster, ClusterGroup, ClusterType + +__all__ = ( + 'ConfigContextSerializer', +) + + +class ConfigContextSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:configcontext-detail') + regions = SerializedPKRelatedField( + queryset=Region.objects.all(), + serializer=RegionSerializer, + nested=True, + required=False, + many=True + ) + site_groups = SerializedPKRelatedField( + queryset=SiteGroup.objects.all(), + serializer=SiteGroupSerializer, + nested=True, + required=False, + many=True + ) + sites = SerializedPKRelatedField( + queryset=Site.objects.all(), + serializer=SiteSerializer, + nested=True, + required=False, + many=True + ) + locations = SerializedPKRelatedField( + queryset=Location.objects.all(), + serializer=LocationSerializer, + nested=True, + required=False, + many=True + ) + device_types = SerializedPKRelatedField( + queryset=DeviceType.objects.all(), + serializer=DeviceTypeSerializer, + nested=True, + required=False, + many=True + ) + roles = SerializedPKRelatedField( + queryset=DeviceRole.objects.all(), + serializer=DeviceRoleSerializer, + nested=True, + required=False, + many=True + ) + platforms = SerializedPKRelatedField( + queryset=Platform.objects.all(), + serializer=PlatformSerializer, + nested=True, + required=False, + many=True + ) + cluster_types = SerializedPKRelatedField( + queryset=ClusterType.objects.all(), + serializer=ClusterTypeSerializer, + nested=True, + required=False, + many=True + ) + cluster_groups = SerializedPKRelatedField( + queryset=ClusterGroup.objects.all(), + serializer=ClusterGroupSerializer, + nested=True, + required=False, + many=True + ) + clusters = SerializedPKRelatedField( + queryset=Cluster.objects.all(), + serializer=ClusterSerializer, + nested=True, + required=False, + many=True + ) + tenant_groups = SerializedPKRelatedField( + queryset=TenantGroup.objects.all(), + serializer=TenantGroupSerializer, + nested=True, + required=False, + many=True + ) + tenants = SerializedPKRelatedField( + queryset=Tenant.objects.all(), + serializer=TenantSerializer, + nested=True, + required=False, + many=True + ) + tags = serializers.SlugRelatedField( + queryset=Tag.objects.all(), + slug_field='slug', + required=False, + many=True + ) + data_source = DataSourceSerializer( + nested=True, + required=False + ) + data_file = DataFileSerializer( + nested=True, + read_only=True + ) + + class Meta: + model = ConfigContext + fields = [ + 'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', + 'locations', 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', + 'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file', 'data_synced', 'data', + 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/extras/api/serializers_/configtemplates.py b/netbox/extras/api/serializers_/configtemplates.py new file mode 100644 index 000000000..935214478 --- /dev/null +++ b/netbox/extras/api/serializers_/configtemplates.py @@ -0,0 +1,30 @@ +from rest_framework import serializers + +from core.api.serializers_.data import DataFileSerializer, DataSourceSerializer +from extras.models import ConfigTemplate +from netbox.api.serializers import ValidatedModelSerializer +from netbox.api.serializers.features import TaggableModelSerializer + +__all__ = ( + 'ConfigTemplateSerializer', +) + + +class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:configtemplate-detail') + data_source = DataSourceSerializer( + nested=True, + required=False + ) + data_file = DataFileSerializer( + nested=True, + required=False + ) + + class Meta: + model = ConfigTemplate + fields = [ + 'id', 'url', 'display', 'name', 'description', 'environment_params', 'template_code', 'data_source', + 'data_path', 'data_file', 'data_synced', 'tags', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/extras/api/serializers_/customfields.py b/netbox/extras/api/serializers_/customfields.py new file mode 100644 index 000000000..082047e94 --- /dev/null +++ b/netbox/extras/api/serializers_/customfields.py @@ -0,0 +1,91 @@ +from django.utils.translation import gettext as _ +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 CustomField, CustomFieldChoiceSet +from netbox.api.fields import ChoiceField, ContentTypeField +from netbox.api.serializers import ValidatedModelSerializer + +__all__ = ( + 'CustomFieldChoiceSetSerializer', + 'CustomFieldSerializer', +) + + +class CustomFieldChoiceSetSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfieldchoiceset-detail') + base_choices = ChoiceField( + choices=CustomFieldChoiceSetBaseChoices, + required=False + ) + extra_choices = serializers.ListField( + child=serializers.ListField( + min_length=2, + max_length=2 + ) + ) + + class Meta: + model = CustomFieldChoiceSet + fields = [ + 'id', 'url', 'display', 'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically', + 'choices_count', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description', 'choices_count') + + +class CustomFieldSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail') + object_types = ContentTypeField( + queryset=ObjectType.objects.with_feature('custom_fields'), + many=True + ) + type = ChoiceField(choices=CustomFieldTypeChoices) + related_object_type = ContentTypeField( + queryset=ObjectType.objects.all(), + required=False, + allow_null=True + ) + filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False) + data_type = serializers.SerializerMethodField() + choice_set = CustomFieldChoiceSetSerializer( + nested=True, + required=False, + allow_null=True + ) + ui_visible = ChoiceField(choices=CustomFieldUIVisibleChoices, required=False) + ui_editable = ChoiceField(choices=CustomFieldUIEditableChoices, required=False) + + class Meta: + model = CustomField + fields = [ + 'id', 'url', 'display', 'object_types', 'type', 'related_object_type', 'data_type', 'name', 'label', + 'group_name', 'description', 'required', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', + 'is_cloneable', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', + 'choice_set', 'comments', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') + + def validate_type(self, value): + if self.instance and self.instance.type != value: + raise serializers.ValidationError(_('Changing the type of custom fields is not supported.')) + + return value + + @extend_schema_field(OpenApiTypes.STR) + def get_data_type(self, obj): + types = CustomFieldTypeChoices + if obj.type == types.TYPE_INTEGER: + return 'integer' + if obj.type == types.TYPE_DECIMAL: + return 'decimal' + if obj.type == types.TYPE_BOOLEAN: + return 'boolean' + if obj.type in (types.TYPE_JSON, types.TYPE_OBJECT): + return 'object' + if obj.type in (types.TYPE_MULTISELECT, types.TYPE_MULTIOBJECT): + return 'array' + return 'string' diff --git a/netbox/extras/api/serializers_/customlinks.py b/netbox/extras/api/serializers_/customlinks.py new file mode 100644 index 000000000..8635ea2a0 --- /dev/null +++ b/netbox/extras/api/serializers_/customlinks.py @@ -0,0 +1,26 @@ +from rest_framework import serializers + +from core.models import ObjectType +from extras.models import CustomLink +from netbox.api.fields import ContentTypeField +from netbox.api.serializers import ValidatedModelSerializer + +__all__ = ( + 'CustomLinkSerializer', +) + + +class CustomLinkSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail') + object_types = ContentTypeField( + queryset=ObjectType.objects.with_feature('custom_links'), + many=True + ) + + class Meta: + model = CustomLink + fields = [ + 'id', 'url', 'display', 'object_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', + 'button_class', 'new_window', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name') diff --git a/netbox/extras/api/serializers_/dashboard.py b/netbox/extras/api/serializers_/dashboard.py new file mode 100644 index 000000000..74d5daecf --- /dev/null +++ b/netbox/extras/api/serializers_/dashboard.py @@ -0,0 +1,13 @@ +from rest_framework import serializers + +from extras.models import Dashboard + +__all__ = ( + 'DashboardSerializer', +) + + +class DashboardSerializer(serializers.ModelSerializer): + class Meta: + model = Dashboard + fields = ('layout', 'config') diff --git a/netbox/extras/api/serializers_/events.py b/netbox/extras/api/serializers_/events.py new file mode 100644 index 000000000..469da3e8c --- /dev/null +++ b/netbox/extras/api/serializers_/events.py @@ -0,0 +1,70 @@ +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.serializers import NetBoxModelSerializer +from utilities.api import get_serializer_for_model +from .scripts import ScriptSerializer + +__all__ = ( + 'EventRuleSerializer', + 'WebhookSerializer', +) + + +# +# Event Rules +# + +class EventRuleSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:eventrule-detail') + object_types = ContentTypeField( + queryset=ObjectType.objects.with_feature('event_rules'), + many=True + ) + action_type = ChoiceField(choices=EventRuleActionChoices) + action_object_type = ContentTypeField( + queryset=ObjectType.objects.with_feature('event_rules'), + ) + action_object = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = EventRule + fields = [ + 'id', 'url', 'display', 'object_types', 'name', 'type_create', 'type_update', 'type_delete', + 'type_job_start', 'type_job_end', 'enabled', 'conditions', 'action_type', 'action_object_type', + 'action_object_id', 'action_object', 'description', 'custom_fields', 'tags', 'created', 'last_updated', + ] + 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 +# + +class WebhookSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail') + + class Meta: + model = Webhook + fields = [ + 'id', 'url', 'display', 'name', 'description', 'payload_url', 'http_method', 'http_content_type', + 'additional_headers', 'body_template', 'secret', 'ssl_verification', 'ca_file_path', 'custom_fields', + 'tags', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/extras/api/serializers_/exporttemplates.py b/netbox/extras/api/serializers_/exporttemplates.py new file mode 100644 index 000000000..43cc061a7 --- /dev/null +++ b/netbox/extras/api/serializers_/exporttemplates.py @@ -0,0 +1,36 @@ +from rest_framework import serializers + +from core.api.serializers_.data import DataFileSerializer, DataSourceSerializer +from core.models import ObjectType +from extras.models import ExportTemplate +from netbox.api.fields import ContentTypeField +from netbox.api.serializers import ValidatedModelSerializer + +__all__ = ( + 'ExportTemplateSerializer', +) + + +class ExportTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail') + object_types = ContentTypeField( + queryset=ObjectType.objects.with_feature('export_templates'), + many=True + ) + data_source = DataSourceSerializer( + nested=True, + required=False + ) + data_file = DataFileSerializer( + nested=True, + read_only=True + ) + + class Meta: + model = ExportTemplate + fields = [ + 'id', 'url', 'display', 'object_types', 'name', 'description', 'template_code', 'mime_type', + 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created', + 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/extras/api/serializers_/journaling.py b/netbox/extras/api/serializers_/journaling.py new file mode 100644 index 000000000..1a44e7e2e --- /dev/null +++ b/netbox/extras/api/serializers_/journaling.py @@ -0,0 +1,60 @@ +from django.contrib.auth import get_user_model +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.serializers import NetBoxModelSerializer +from utilities.api import get_serializer_for_model + +__all__ = ( + 'JournalEntrySerializer', +) + + +class JournalEntrySerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:journalentry-detail') + assigned_object_type = ContentTypeField( + queryset=ObjectType.objects.all() + ) + assigned_object = serializers.SerializerMethodField(read_only=True) + created_by = serializers.PrimaryKeyRelatedField( + allow_null=True, + queryset=get_user_model().objects.all(), + required=False, + default=serializers.CurrentUserDefault() + ) + kind = ChoiceField( + choices=JournalEntryKindChoices, + required=False + ) + + class Meta: + model = JournalEntry + fields = [ + 'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created', + 'created_by', 'kind', 'comments', 'tags', 'custom_fields', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'created') + + def validate(self, data): + + # Validate that the parent object exists + if not self.nested and 'assigned_object_type' in data and 'assigned_object_id' in data: + try: + data['assigned_object_type'].get_object_for_this_type(id=data['assigned_object_id']) + except ObjectDoesNotExist: + raise serializers.ValidationError( + f"Invalid assigned_object: {data['assigned_object_type']} ID {data['assigned_object_id']}" + ) + + 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_/objecttypes.py b/netbox/extras/api/serializers_/objecttypes.py new file mode 100644 index 000000000..8e4806652 --- /dev/null +++ b/netbox/extras/api/serializers_/objecttypes.py @@ -0,0 +1,16 @@ +from rest_framework import serializers + +from core.models import ObjectType +from netbox.api.serializers import BaseModelSerializer + +__all__ = ( + 'ObjectTypeSerializer', +) + + +class ObjectTypeSerializer(BaseModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:objecttype-detail') + + class Meta: + model = ObjectType + fields = ['id', 'url', 'display', 'app_label', 'model'] diff --git a/netbox/extras/api/serializers_/savedfilters.py b/netbox/extras/api/serializers_/savedfilters.py new file mode 100644 index 000000000..9e26f0c30 --- /dev/null +++ b/netbox/extras/api/serializers_/savedfilters.py @@ -0,0 +1,26 @@ +from rest_framework import serializers + +from core.models import ObjectType +from extras.models import SavedFilter +from netbox.api.fields import ContentTypeField +from netbox.api.serializers import ValidatedModelSerializer + +__all__ = ( + 'SavedFilterSerializer', +) + + +class SavedFilterSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:savedfilter-detail') + object_types = ContentTypeField( + queryset=ObjectType.objects.all(), + many=True + ) + + class Meta: + model = SavedFilter + fields = [ + 'id', 'url', 'display', 'object_types', 'name', 'slug', 'description', 'user', 'weight', 'enabled', + 'shared', 'parameters', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description') diff --git a/netbox/extras/api/serializers_/scripts.py b/netbox/extras/api/serializers_/scripts.py new file mode 100644 index 000000000..b2a8ef29d --- /dev/null +++ b/netbox/extras/api/serializers_/scripts.py @@ -0,0 +1,77 @@ +from django.utils.translation import gettext as _ +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from core.api.serializers_.jobs import JobSerializer +from extras.models import Script +from netbox.api.serializers import ValidatedModelSerializer + +__all__ = ( + 'ScriptDetailSerializer', + 'ScriptInputSerializer', + 'ScriptSerializer', +) + + +class ScriptSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:script-detail') + description = serializers.SerializerMethodField(read_only=True) + vars = serializers.SerializerMethodField(read_only=True) + result = JobSerializer(nested=True, read_only=True) + + class Meta: + model = Script + fields = [ + 'id', 'url', 'module', 'name', 'description', 'vars', 'result', 'display', 'is_executable', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_vars(self, obj): + if obj.python_class: + return { + k: v.__class__.__name__ for k, v in obj.python_class()._get_vars().items() + } + else: + return {} + + @extend_schema_field(serializers.CharField()) + def get_display(self, obj): + return f'{obj.name} ({obj.module})' + + @extend_schema_field(serializers.CharField()) + def get_description(self, obj): + if obj.python_class: + return obj.python_class().description + else: + return None + + +class ScriptDetailSerializer(ScriptSerializer): + result = serializers.SerializerMethodField(read_only=True) + + @extend_schema_field(JobSerializer()) + def get_result(self, obj): + job = obj.jobs.all().order_by('-created').first() + context = { + 'request': self.context['request'] + } + data = JobSerializer(job, context=context).data + return data + + +class ScriptInputSerializer(serializers.Serializer): + data = serializers.JSONField() + commit = serializers.BooleanField() + schedule_at = serializers.DateTimeField(required=False, allow_null=True) + interval = serializers.IntegerField(required=False, allow_null=True) + + def validate_schedule_at(self, value): + if value and not self.context['script'].scheduling_enabled: + raise serializers.ValidationError(_("Scheduling is not enabled for this script.")) + return value + + def validate_interval(self, value): + if value and not self.context['script'].scheduling_enabled: + raise serializers.ValidationError(_("Scheduling is not enabled for this script.")) + return value diff --git a/netbox/extras/api/serializers_/tags.py b/netbox/extras/api/serializers_/tags.py new file mode 100644 index 000000000..9d91ba5e1 --- /dev/null +++ b/netbox/extras/api/serializers_/tags.py @@ -0,0 +1,30 @@ +from rest_framework import serializers + +from core.models import ObjectType +from extras.models import Tag +from netbox.api.fields import ContentTypeField, RelatedObjectCountField +from netbox.api.serializers import ValidatedModelSerializer + +__all__ = ( + 'TagSerializer', +) + + +class TagSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail') + object_types = ContentTypeField( + queryset=ObjectType.objects.with_feature('tags'), + many=True, + required=False + ) + + # Related object counts + tagged_items = RelatedObjectCountField('extras_taggeditem_items') + + class Meta: + model = Tag + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'object_types', 'tagged_items', 'created', + 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'slug', 'color', 'description') diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 1616b8554..301cc1b0a 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -20,10 +20,9 @@ router.register('image-attachments', views.ImageAttachmentViewSet) router.register('journal-entries', views.JournalEntryViewSet) router.register('config-contexts', views.ConfigContextViewSet) router.register('config-templates', views.ConfigTemplateViewSet) -router.register('reports', views.ReportViewSet, basename='report') router.register('scripts', views.ScriptViewSet, basename='script') router.register('object-changes', views.ObjectChangeViewSet) -router.register('content-types', views.ContentTypeViewSet) +router.register('object-types', views.ObjectTypeViewSet) app_name = 'extras-api' urlpatterns = [ diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index e0fca8617..05087b2d5 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,4 +1,3 @@ -from django.contrib.contenttypes.models import ContentType from django.http import Http404 from django.shortcuts import get_object_or_404 from django_rq.queues import get_connection @@ -9,22 +8,20 @@ from rest_framework.generics import RetrieveUpdateDestroyAPIView from rest_framework.renderers import JSONRenderer from rest_framework.response import Response from rest_framework.routers import APIRootView -from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet +from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from rq import Worker -from core.choices import JobStatusChoices -from core.models import Job +from core.models import Job, ObjectType from extras import filtersets from extras.models import * -from extras.reports import get_module_and_report, run_report -from extras.scripts import get_module_and_script, run_script +from extras.scripts import run_script from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.features import SyncedDataMixin from netbox.api.metadata import ContentTypeMetadata from netbox.api.renderers import TextRenderer from netbox.api.viewsets import NetBoxModelViewSet from utilities.exceptions import RQWorkerNotRunningException -from utilities.utils import copy_safe_request, count_related +from utilities.request import copy_safe_request from . import serializers from .mixins import ConfigTemplateRenderMixin @@ -116,7 +113,7 @@ class CustomLinkViewSet(NetBoxModelViewSet): class ExportTemplateViewSet(SyncedDataMixin, NetBoxModelViewSet): metadata_class = ContentTypeMetadata - queryset = ExportTemplate.objects.prefetch_related('data_source', 'data_file') + queryset = ExportTemplate.objects.all() serializer_class = serializers.ExportTemplateSerializer filterset_class = filtersets.ExportTemplateFilterSet @@ -148,9 +145,7 @@ class BookmarkViewSet(NetBoxModelViewSet): # class TagViewSet(NetBoxModelViewSet): - queryset = Tag.objects.annotate( - tagged_items=count_related(TaggedItem, 'tag') - ) + queryset = Tag.objects.all() serializer_class = serializers.TagSerializer filterset_class = filtersets.TagFilterSet @@ -182,10 +177,7 @@ class JournalEntryViewSet(NetBoxModelViewSet): # class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet): - queryset = ConfigContext.objects.prefetch_related( - 'regions', 'site_groups', 'sites', 'locations', 'roles', 'platforms', 'tenant_groups', 'tenants', 'data_source', - 'data_file', - ) + queryset = ConfigContext.objects.all() serializer_class = serializers.ConfigContextSerializer filterset_class = filtersets.ConfigContextFilterSet @@ -195,7 +187,7 @@ class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet): # class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxModelViewSet): - queryset = ConfigTemplate.objects.prefetch_related('data_source', 'data_file') + queryset = ConfigTemplate.objects.all() serializer_class = serializers.ConfigTemplateSerializer filterset_class = filtersets.ConfigTemplateFilterSet @@ -211,175 +203,45 @@ class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxMo return self.render_configtemplate(request, configtemplate, context) -# -# Reports -# - -class ReportViewSet(ViewSet): - permission_classes = [IsAuthenticatedOrLoginNotRequired] - _ignore_model_permissions = True - schema = None - lookup_value_regex = '[^/]+' # Allow dots - - def _get_report(self, pk): - try: - module_name, report_name = pk.split('.', maxsplit=1) - except ValueError: - raise Http404 - - module, report = get_module_and_report(module_name, report_name) - if report is None: - raise Http404 - - return module, report - - def list(self, request): - """ - Compile all reports and their related results (if any). Result data is deferred in the list view. - """ - results = { - job.name: job - for job in Job.objects.filter( - object_type=ContentType.objects.get(app_label='extras', model='reportmodule'), - status__in=JobStatusChoices.TERMINAL_STATE_CHOICES - ).order_by('name', '-created').distinct('name').defer('data') - } - - report_list = [] - for report_module in ReportModule.objects.restrict(request.user): - report_list.extend([report() for report in report_module.reports.values()]) - - # Attach Job objects to each report (if any) - for report in report_list: - report.result = results.get(report.name, None) - - serializer = serializers.ReportSerializer(report_list, many=True, context={ - 'request': request, - }) - - return Response({'count': len(report_list), 'results': serializer.data}) - - def retrieve(self, request, pk): - """ - Retrieve a single Report identified as ".". - """ - module, report = self._get_report(pk) - - # Retrieve the Report and Job, if any. - object_type = ContentType.objects.get(app_label='extras', model='reportmodule') - report.result = Job.objects.filter( - object_type=object_type, - name=report.name, - status__in=JobStatusChoices.TERMINAL_STATE_CHOICES - ).first() - - serializer = serializers.ReportDetailSerializer(report, context={ - 'request': request - }) - - return Response(serializer.data) - - @action(detail=True, methods=['post']) - def run(self, request, pk): - """ - Run a Report identified as ". - @@ -48,19 +38,8 @@ /> - - @@ -71,38 +50,13 @@ src="{% static 'netbox.js' %}?v={{ settings.VERSION }}" onerror="window.location='{% url 'media_failure' %}?filename=netbox.js'"> + {% django_htmx_script %} {# Additional content #} {% block head %}{% endblock %} - + - {# Page layout #} {% block layout %}{% endblock %} @@ -113,10 +67,5 @@ {# User messages #} {% include 'inc/messages.html' %} - {# Data container #} -
- {% block data %}{% endblock %} -
- diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index bb3bbc0e1..d53591cb4 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -1,6 +1,7 @@ {# Base layout for the core NetBox UI w/navbar and page content #} {% extends 'base/base.html' %} {% load helpers %} +{% load navigation %} {% load static %} {% load i18n %} @@ -14,167 +15,190 @@ Blocks: {% block layout %} -
-
+
+ {# Sidebar #} - {% include 'base/sidenav.html' %} + + + {# Top menu #} + - {# Empty spacer column to ensure search is centered. #} -
+ {# Page content #} +
+
- {# Search bar #} -
- {% include 'inc/searchbar.html' %} -
- - {# Proflie/login button #} -
- {% include 'inc/profile_button.html' %} -
- -
- - + {# Page header #} + {% block header %} + {# Top banner #} {% if config.BANNER_TOP %} -
- {{ config.BANNER_TOP|safe }} -
+ {% include 'inc/banner.html' with content=config.BANNER_TOP %} {% endif %} + {# /Top banner #} + {# Alerts #} {% if settings.DEBUG and not settings.DEVELOPER %} - + {% include 'inc/alerts/warning.html' with title="Debug mode is enabled" message="Performance may be limited. Debugging should never be enabled on a production system." %} {% endif %} {% if config.MAINTENANCE_MODE and config.BANNER_MAINTENANCE %} - + {% include 'inc/alerts/warning.html' with title="Maintenance Mode" message=config.BANNER_MAINTENANCE|safe %} {% endif %} + {# /Alerts #} - {# Page header #} - {% block header %} -
+ {% endblock header %} + {# /Page header #} - {# Title #} -
- {# Center-align title in object-edit views #} -

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

- {% block subtitle %}{% endblock %} -
+ {# Page body #} + {% block page %} +
+
- {# Controls #} - {% block controls %}{% endblock controls %} + {# Page content #} + {% block content %}{% endblock %} + {# /Page content #}
- {% endblock header %} - - {# Page content #} -
- {% block tabs %}{% endblock %} - {% block content-wrapper %} -
- {% block content %}{% endblock %} -
- {% endblock %} - {# Bottom banner #} - {% if config.BANNER_BOTTOM %} -
- {{ config.BANNER_BOTTOM|safe }} -
- {% endif %}
+ {% endblock page %} + {# /Page body #} - {# BS5 pop-up modals #} - {% block modals %}{% endblock %} + {# Bottom banner #} + {% if config.BANNER_BOTTOM %} + {% include 'inc/banner.html' with content=config.BANNER_BOTTOM %} + {% endif %} + {# /Bottom banner #} - {# Page footer #} -
- {% block footer %} -
- -
- -
- -
- {% annotated_now %} {% now 'T' %} - {{ settings.HOSTNAME }} (v{{ settings.VERSION }}) -
- -
- {% endblock footer %} -
+ {# BS5 pop-up modals #} + {% block modals %}{% endblock %}
-
+ {# Page footer #} +
+
+ {% block footer %} + + {# Footer links #} +
    + {% block footer_links %} + {# Documentation #} +
  • + + + +
  • + {# REST API #} +
  • + + + +
  • + {# API docs #} +
  • + + + +
  • + {# GraphQL API #} + {% if config.GRAPHQL_ENABLED %} +
  • + + + +
  • + {% endif %} + {# GitHub #} +
  • + + + +
  • + {# NetDev Slack #} +
  • + + + +
  • + {% endblock footer_links %} +
+ {# /Footer links #} + + {# Footer text #} + + {# /Footer text #} + + {% endblock footer %} +
+
+ {# /Page footer #} + + {# /Page content #} +
+ {% endblock layout %} diff --git a/netbox/templates/base/sidenav.html b/netbox/templates/base/sidenav.html deleted file mode 100644 index 2743ea058..000000000 --- a/netbox/templates/base/sidenav.html +++ /dev/null @@ -1,44 +0,0 @@ -{% load navigation %} -{% load static %} -{% load i18n %} - - diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 9bc5d4a6d..fb3d8185a 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -13,55 +13,53 @@
{% trans "Circuit" %}
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{% trans "Provider" %}{{ object.provider|linkify }}
{% trans "Account" %}{{ object.provider_account|linkify|placeholder }}
{% trans "Circuit ID" %}{{ object.cid }}
{% trans "Type" %}{{ object.type|linkify }}
{% 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 "Install Date" %}{{ object.install_date|annotated_date|placeholder }}
{% trans "Termination Date" %}{{ object.termination_date|annotated_date|placeholder }}
{% trans "Commit Rate" %}{{ object.commit_rate|humanize_speed|placeholder }}
{% trans "Description" %}{{ object.description|placeholder }}
-
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Provider" %}{{ object.provider|linkify }}
{% trans "Account" %}{{ object.provider_account|linkify|placeholder }}
{% trans "Circuit ID" %}{{ object.cid }}
{% trans "Type" %}{{ object.type|linkify }}
{% 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 "Install Date" %}{{ object.install_date|isodate|placeholder }}
{% trans "Termination Date" %}{{ object.termination_date|isodate|placeholder }}
{% trans "Commit Rate" %}{{ object.commit_rate|humanize_speed|placeholder }}
{% trans "Description" %}{{ object.description|placeholder }}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} diff --git a/netbox/templates/circuits/circuittermination.html b/netbox/templates/circuits/circuittermination.html new file mode 100644 index 000000000..d74d2c636 --- /dev/null +++ b/netbox/templates/circuits/circuittermination.html @@ -0,0 +1,51 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load i18n %} + +{% block breadcrumbs %} + {{ block.super }} + +{% endblock %} + +{% block content %} +
+
+ +
+ {% if object %} + + + + + + + + + + {% include 'circuits/inc/circuit_termination_fields.html' with termination=object %} +
{% trans "Circuit" %} + {{ object.circuit|linkify }} +
{% trans "Provider" %} + {{ object.circuit.provider|linkify }} +
+ {% else %} +
+ {% trans "None" %} +
+ {% endif %} +
+ {% plugin_left_page object %} +
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html deleted file mode 100644 index 907c5a3d7..000000000 --- a/netbox/templates/circuits/circuittermination_edit.html +++ /dev/null @@ -1,58 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load static %} -{% load form_helpers %} -{% load i18n %} - -{% block form %} -
-
-
{% trans "Circuit Termination" %}
-
- {% render_field form.circuit %} - {% render_field form.term_side %} - {% render_field form.tags %} - {% render_field form.mark_connected %} - {% with providernetwork_tab_active=form.initial.provider_network %} -
-
- -
-
-
-
- {% render_field form.site %} -
-
- {% render_field form.provider_network %} -
-
- {% endwith %} -
- -
-
-
{% trans "Termination Details" %}
-
- {% render_field form.port_speed %} - {% render_field form.upstream_speed %} - {% render_field form.xconnect_id %} - {% render_field form.pp_info %} - {% render_field form.description %} -
- - {% if form.custom_fields %} -
-
-
{% trans "Custom Fields" %}
-
- {% render_custom_fields form %} -
- {% endif %} -{% endblock %} diff --git a/netbox/templates/circuits/circuittype.html b/netbox/templates/circuits/circuittype.html index 407ee4042..2f63eaf8a 100644 --- a/netbox/templates/circuits/circuittype.html +++ b/netbox/templates/circuits/circuittype.html @@ -6,7 +6,7 @@ {% block extra_controls %} {% if perms.circuits.add_circuit %} - + {% trans "Add Circuit" %} {% endif %} @@ -16,31 +16,27 @@
-
- {% trans "Circuit Type" %} -
-
- - - - - - - - - - - - - -
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Color" %} - {% if object.color %} -   - {% else %} - {{ ''|placeholder }} - {% endif %} -
-
+
{% trans "Circuit Type" %}
+ + + + + + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Color" %} + {% if object.color %} +   + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% include 'inc/panels/tags.html' %} {% plugin_left_page object %} diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index 7ca83d265..acec208c0 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -2,121 +2,34 @@ {% load i18n %}
-
-
- {% if not termination and perms.circuits.add_circuittermination %} - - {% trans "Add" %} - - {% endif %} - {% if termination and perms.circuits.change_circuittermination %} - - {% trans "Edit" %} - - - {% trans "Swap" %} - - {% endif %} - {% if termination and perms.circuits.delete_circuittermination %} - - {% trans "Delete" %} - - {% endif %} -
-
{% blocktrans %}Termination {{ side }}{% endblocktrans %}
-
-
+
+ {% blocktrans %}Termination{% endblocktrans %} {{ side }} +
+ {% if not termination and perms.circuits.add_circuittermination %} + + {% trans "Add" %} + + {% endif %} + {% if termination and perms.circuits.change_circuittermination %} + + {% trans "Edit" %} + + + {% trans "Swap" %} + + {% endif %} + {% if termination and perms.circuits.delete_circuittermination %} + + {% trans "Delete" %} + + {% endif %} +
+
{% if termination %} - {% if termination.site %} + {% include 'circuits/inc/circuit_termination_fields.html' with termination=termination %} - - - - - - - - {% else %} - - - - - {% endif %} - - - - - - - - - - - - - - - - - - +
{% trans "Site" %} - {% if termination.site.region %} - {{ termination.site.region|linkify }} / - {% endif %} - {{ termination.site|linkify }} -
{% trans "Termination" %} - {% if termination.mark_connected %} - - {% trans "Marked as connected" %} - {% elif termination.cable %} - {{ termination.cable }} {% trans "to" %} - {% for peer in termination.link_peers %} - {% if peer.device %} - {{ peer.device|linkify }}
- {% elif peer.circuit %} - {{ peer.circuit|linkify }}
- {% endif %} - {{ peer|linkify }}{% if not forloop.last %},{% endif %} - {% endfor %} -
- - {% trans "Trace" %} - - {% if perms.dcim.change_cable %} - - {% trans "Edit" %} - - {% endif %} - {% if perms.dcim.delete_cable %} - - {% trans "Disconnect" %} - - {% endif %} -
- {% elif perms.dcim.add_cable %} - - {% endif %} -
{% trans "Provider Network" %}{{ termination.provider_network.provider|linkify }} / {{ termination.provider_network|linkify }}
{% trans "Speed" %} - {% if termination.port_speed and termination.upstream_speed %} - {{ termination.port_speed|humanize_speed }}   - {{ termination.upstream_speed|humanize_speed }} - {% elif termination.port_speed %} - {{ termination.port_speed|humanize_speed }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Cross-Connect" %}{{ termination.xconnect_id|placeholder }}
{% trans "Patch Panel/Port" %}{{ termination.pp_info|placeholder }}
{% trans "Description" %}{{ termination.description|placeholder }}
{% trans "Tags" %}{% trans "Tags" %} {% for tag in termination.tags.all %} {% tag tag %} @@ -151,7 +64,8 @@ {% endfor %}
{% else %} +
{% trans "None" %} +
{% endif %} -
diff --git a/netbox/templates/circuits/inc/circuit_termination_fields.html b/netbox/templates/circuits/inc/circuit_termination_fields.html new file mode 100644 index 000000000..97d194f24 --- /dev/null +++ b/netbox/templates/circuits/inc/circuit_termination_fields.html @@ -0,0 +1,90 @@ +{% load helpers %} +{% load i18n %} + +{% if termination.site %} + + {% trans "Site" %} + + {% if termination.site.region %} + {{ termination.site.region|linkify }} / + {% endif %} + {{ termination.site|linkify }} + + + + {% trans "Termination" %} + + {% if termination.mark_connected %} + + {% trans "Marked as connected" %} + {% elif termination.cable %} + {{ termination.cable }} {% trans "to" %} + {% for peer in termination.link_peers %} + {% if peer.device %} + {{ peer.device|linkify }}
+ {% elif peer.circuit %} + {{ peer.circuit|linkify }}
+ {% endif %} + {{ peer|linkify }}{% if not forloop.last %},{% endif %} + {% endfor %} +
+ + {% trans "Trace" %} + + {% if perms.dcim.change_cable %} + + {% trans "Edit" %} + + {% endif %} + {% if perms.dcim.delete_cable %} + + {% trans "Disconnect" %} + + {% endif %} +
+ {% elif perms.dcim.add_cable %} + + {% endif %} + + +{% else %} + + {% trans "Provider Network" %} + {{ termination.provider_network.provider|linkify }} / {{ termination.provider_network|linkify }} + +{% endif %} + + {% trans "Speed" %} + + {% if termination.port_speed and termination.upstream_speed %} + {{ termination.port_speed|humanize_speed }}   + {{ termination.upstream_speed|humanize_speed }} + {% elif termination.port_speed %} + {{ termination.port_speed|humanize_speed }} + {% else %} + {{ ''|placeholder }} + {% endif %} + + + + {% trans "Cross-Connect" %} + {{ termination.xconnect_id|placeholder }} + + + {% trans "Patch Panel/Port" %} + {{ termination.pp_info|placeholder }} + + + {% trans "Description" %} + {{ termination.description|placeholder }} + diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 6fabb304d..44ec1e4af 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -7,7 +7,7 @@ {% block extra_controls %} {% if perms.circuits.add_circuit %} - + {% trans "Add circuit" %} {% endif %} @@ -17,25 +17,23 @@
-
{% trans "Provider" %}
-
- - - - - - - - - -
{% trans "ASNs" %} - {% for asn in object.asns.all %} - {{ asn|linkify }}{% if not forloop.last %}, {% endif %} - {% empty %} - {{ ''|placeholder }} - {% endfor %} -
{% trans "Description" %}{{ object.description|placeholder }}
-
+
{% trans "Provider" %}
+ + + + + + + + + +
{% trans "ASNs" %} + {% for asn in object.asns.all %} + {{ asn|linkify }}{% if not forloop.last %}, {% endif %} + {% empty %} + {{ ''|placeholder }} + {% endfor %} +
{% trans "Description" %}{{ object.description|placeholder }}
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} @@ -51,18 +49,13 @@
{% trans "Provider Accounts" %}
-
+ {% htmx_table 'circuits:provideraccount_list' provider_id=object.pk %}
+
{% trans "Circuits" %}
-
+ {% htmx_table 'circuits:circuit_list' provider_id=object.pk %}
{% plugin_full_width_page object %}
diff --git a/netbox/templates/circuits/provideraccount.html b/netbox/templates/circuits/provideraccount.html index d2222a174..9042bd98a 100644 --- a/netbox/templates/circuits/provideraccount.html +++ b/netbox/templates/circuits/provideraccount.html @@ -15,22 +15,20 @@
{% trans "Provider Account" %}
-
- - - - - - - - - - - - - -
{% trans "Provider" %}{{ object.provider|linkify }}
{% trans "Account" %}{{ object.account }}
{% trans "Name" %}{{ object.name|placeholder }}
-
+ + + + + + + + + + + + + +
{% trans "Provider" %}{{ object.provider|linkify }}
{% trans "Account" %}{{ object.account }}
{% trans "Name" %}{{ object.name|placeholder }}
{% include 'inc/panels/tags.html' %} {% plugin_left_page object %} @@ -44,10 +42,7 @@
{% trans "Circuits" %}
-
+ {% htmx_table 'circuits:circuit_list' provider_account_id=object.pk %}
{% plugin_full_width_page object %}
diff --git a/netbox/templates/circuits/providernetwork.html b/netbox/templates/circuits/providernetwork.html index 2fbd57f43..f074db590 100644 --- a/netbox/templates/circuits/providernetwork.html +++ b/netbox/templates/circuits/providernetwork.html @@ -11,53 +11,46 @@ {% endblock %} {% block content %} -
-
-
-
- {% trans "Provider Network" %} -
-
- - - - - - - - - - - - - - - - - -
{% trans "Provider" %}{{ object.provider|linkify }}
{% trans "Name" %}{{ object.name }}
{% trans "Service ID" %}{{ object.service_id|placeholder }}
{% trans "Description" %}{{ object.description|placeholder }}
-
-
- {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} +
+
+
+
{% trans "Provider Network" %}
+ + + + + + + + + + + + + + + + + +
{% trans "Provider" %}{{ object.provider|linkify }}
{% trans "Name" %}{{ object.name }}
{% trans "Service ID" %}{{ object.service_id|placeholder }}
{% trans "Description" %}{{ object.description|placeholder }}
+
+ {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %}
- {% include 'inc/panels/related_objects.html' %} - {% include 'inc/panels/comments.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% plugin_right_page object %} + {% include 'inc/panels/related_objects.html' %} + {% include 'inc/panels/comments.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
+
+
+
+
+
{% trans "Circuits" %}
+ {% htmx_table 'circuits:circuit_list' provider_network_id=object.pk %} +
+ {% plugin_full_width_page object %}
-
-
-
-
-
{% trans "Circuits" %}
-
-
- {% plugin_full_width_page object %}
-
{% endblock %} diff --git a/netbox/templates/core/configrevision.html b/netbox/templates/core/configrevision.html index 7e7f49f2f..71831e161 100644 --- a/netbox/templates/core/configrevision.html +++ b/netbox/templates/core/configrevision.html @@ -10,28 +10,20 @@ {% block breadcrumbs %} {% endblock %} -{% block controls %} -
-
- {% plugin_buttons object %} - {% if not object.pk or object.is_active and perms.core.add_configrevision %} - {% url 'core:configrevision_add' as edit_url %} - {% include "buttons/edit.html" with url=edit_url %} - {% endif %} - {% if object.pk and not object.is_active and perms.core.delete_configrevision %} - {% delete_button object %} - {% endif %} -
-
- {% custom_links object %} -
-
-{% endblock controls %} +{% block control-buttons %} + {% if not object.pk or object.is_active and perms.core.add_configrevision %} + {% url 'core:configrevision_add' as edit_url %} + {% include "buttons/edit.html" with url=edit_url %} + {% endif %} + {% if object.pk and not object.is_active and perms.core.delete_configrevision %} + {% delete_button object %} + {% endif %} +{% endblock control-buttons %} {% block subtitle %} {% if object.created %} -
- {% trans "Created" %} {{ object.created|annotated_date }} +
+ {% trans "Created" %} {{ object.created|isodatetime }}
{% endif %} {% endblock subtitle %} @@ -40,181 +32,8 @@
-
{% trans "Rack Elevations" %}
-
- - - - - - - - - -
{% trans "Default unit height" %}{{ object.data.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT }}
{% trans "Default unit width" %}{{ object.data.RACK_ELEVATION_DEFAULT_UNIT_WIDTH }}
-
-
- -
-
{% trans "Power Feeds" %}
-
- - - - - - - - - - - - - -
{% trans "Default voltage" %}{{ object.data.POWERFEED_DEFAULT_VOLTAGE }}
{% trans "Default amperage" %}{{ object.data.POWERFEED_DEFAULT_AMPERAGE }}
{% trans "Default max utilization" %}{{ object.data.POWERFEED_DEFAULT_MAX_UTILIZATION }}
-
-
- -
-
{% trans "IPAM" %}
-
- - - - - - - - - -
{% trans "Enforce global unique" %}{{ object.data.ENFORCE_GLOBAL_UNIQUE }}
{% trans "Prefer IPv4" %}{{ object.data.PREFER_IPV4 }}
-
-
- -
-
{% trans "Security" %}
-
- - - - - -
{% trans "Allowed URL schemes" %}{{ object.data.ALLOWED_URL_SCHEMES|join:", "|placeholder }}
-
-
- -
-
{% trans "Banners" %}
-
- - - - - - - - - - - - - - - - - -
{% trans "Login banner" %}{{ object.data.BANNER_LOGIN }}
{% trans "Maintenance banner" %}{{ object.data.BANNER_MAINTENANCE }}
{% trans "Top banner" %}{{ object.data.BANNER_TOP }}
{% trans "Bottom banner" %}{{ object.data.BANNER_BOTTOM }}
-
-
- -
-
{% trans "Pagination" %}
-
- - - - - - - - - -
{% trans "Paginate count" %}{{ object.data.PAGINATE_COUNT }}
{% trans "Max page size" %}{{ object.data.MAX_PAGE_SIZE }}
-
-
- -
-
{% trans "Validation" %}
-
- - - - {% if object.data.CUSTOM_VALIDATORS %} - - {% else %} - - {% endif %} - - - - {% if object.data.PROTECTION_RULES %} - - {% else %} - - {% endif %} - -
{% trans "Custom validators" %} -
{{ object.data.CUSTOM_VALIDATORS|json }}
-
{{ ''|placeholder }}
{% trans "Protection rules" %} -
{{ object.data.PROTECTION_RULES|json }}
-
{{ ''|placeholder }}
-
-
- -
-
{% trans "User Preferences" %}
-
- - - - {% if object.data.DEFAULT_USER_PREFERENCES %} - - {% else %} - - {% endif %} - -
{% trans "Default user preferences" %} -
{{ object.data.DEFAULT_USER_PREFERENCES|json }}
-
{{ ''|placeholder }}
-
-
- -
-
{% trans "Miscellaneous" %}
-
- - - - - - - - - - - - - - - - - - - - - -
{% trans "Maintenance mode" %}{{ object.data.MAINTENANCE_MODE }}
{% trans "GraphQL enabled" %}{{ object.data.GRAPHQL_ENABLED }}
{% trans "Changelog retention" %}{{ object.data.CHANGELOG_RETENTION }}
{% trans "Job retention" %}{{ object.data.JOB_RETENTION }}
{% trans "Maps URL" %}{{ object.data.MAPS_URL }}
-
+
{% trans "Configuration Data" %}
+ {% include 'core/inc/config_data.html' with config=object.data %}
diff --git a/netbox/templates/core/configrevision_restore.html b/netbox/templates/core/configrevision_restore.html index ad6fb1bd9..ee0ce1ebe 100644 --- a/netbox/templates/core/configrevision_restore.html +++ b/netbox/templates/core/configrevision_restore.html @@ -1,4 +1,4 @@ -{% extends 'base/layout.html' %} +{% extends 'generic/object.html' %} {% load helpers %} {% load buttons %} {% load perms %} @@ -8,37 +8,19 @@ {% block title %}{% trans "Restore" %}: {{ object }}{% endblock %} {% block subtitle %} -
- {% trans "Created" %} {{ object.created|annotated_date }} +
+ {% trans "Created" %} {{ object.created|isodatetime }}
{% endblock %} -{% block header %} - - {{ block.super }} -{% endblock header %} - -{% block controls %} -
-
- {% if request.user|can_delete:job %} - {% delete_button job %} - {% endif %} -
-
-{% endblock controls %} +{% block control-buttons %} + {% if request.user|can_delete:job %} + {% delete_button job %} + {% endif %} +{% endblock control-buttons %} {% block tabs %} -