diff --git a/.github/ISSUE_TEMPLATE/01-feature_request.yaml b/.github/ISSUE_TEMPLATE/01-feature_request.yaml index cb39ae9be..a810b7a4c 100644 --- a/.github/ISSUE_TEMPLATE/01-feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/01-feature_request.yaml @@ -1,5 +1,6 @@ --- name: ✨ Feature Request +type: Feature description: Propose a new NetBox feature or enhancement labels: ["type: feature", "status: needs triage"] body: @@ -14,7 +15,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v4.1.11 + placeholder: v4.3.0 validations: required: true - type: dropdown @@ -27,19 +28,6 @@ body: - Other validations: required: true - - type: dropdown - attributes: - label: Triage priority - description: > - Issue triage may be prioritized in some cases. Select whichever of the following - conditions applies, if any. - options: - - I volunteer to perform this work (if approved) - - I'm a NetBox Labs customer - - N/A - default: 2 - validations: - required: true - type: textarea attributes: label: Proposed functionality diff --git a/.github/ISSUE_TEMPLATE/02-bug_report.yaml b/.github/ISSUE_TEMPLATE/02-bug_report.yaml index e42ff3045..5194e7bc6 100644 --- a/.github/ISSUE_TEMPLATE/02-bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/02-bug_report.yaml @@ -1,5 +1,6 @@ --- name: 🐛 Bug Report +type: Bug description: Report a reproducible bug in the current release of NetBox labels: ["type: bug", "status: needs triage"] body: @@ -22,24 +23,11 @@ body: - Self-hosted validations: required: true - - type: dropdown - attributes: - label: Triage priority - description: > - Issue triage may be prioritized in some cases. Select whichever of the following - conditions applies, if any. - options: - - I volunteer to perform this work (if approved) - - I'm a NetBox Labs customer - - N/A - default: 2 - validations: - required: true - type: input attributes: label: NetBox Version description: What version of NetBox are you currently running? - placeholder: v4.1.11 + placeholder: v4.3.0 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/03-documentation_change.yaml b/.github/ISSUE_TEMPLATE/03-documentation_change.yaml index b5a970782..2dea61acc 100644 --- a/.github/ISSUE_TEMPLATE/03-documentation_change.yaml +++ b/.github/ISSUE_TEMPLATE/03-documentation_change.yaml @@ -1,5 +1,6 @@ --- name: 📖 Documentation Change +type: Documentation description: Suggest an addition or modification to the NetBox documentation labels: ["type: documentation", "status: needs triage"] body: diff --git a/.github/ISSUE_TEMPLATE/04-translation.yaml b/.github/ISSUE_TEMPLATE/04-translation.yaml index d07bc399d..72130ae47 100644 --- a/.github/ISSUE_TEMPLATE/04-translation.yaml +++ b/.github/ISSUE_TEMPLATE/04-translation.yaml @@ -1,5 +1,6 @@ --- name: 🌍 Translation +type: Translation description: Request support for a new language in the user interface labels: ["type: translation"] body: diff --git a/.github/ISSUE_TEMPLATE/05-housekeeping.yaml b/.github/ISSUE_TEMPLATE/05-housekeeping.yaml index 777871395..65b983e18 100644 --- a/.github/ISSUE_TEMPLATE/05-housekeeping.yaml +++ b/.github/ISSUE_TEMPLATE/05-housekeeping.yaml @@ -1,5 +1,6 @@ --- name: 🏡 Housekeeping +type: Housekeeping description: A change pertaining to the codebase itself (developers only) labels: ["type: housekeeping"] body: diff --git a/.github/ISSUE_TEMPLATE/06-deprecation.yaml b/.github/ISSUE_TEMPLATE/06-deprecation.yaml index 27e13e5c0..83905a39a 100644 --- a/.github/ISSUE_TEMPLATE/06-deprecation.yaml +++ b/.github/ISSUE_TEMPLATE/06-deprecation.yaml @@ -1,5 +1,6 @@ --- name: 🗑️ Deprecation +type: Deprecation description: The removal of an existing feature or resource labels: ["type: deprecation"] body: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index efbf38932..5b18f4525 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -2,7 +2,7 @@ blank_issues_enabled: false contact_links: - name: 📖 Contributing Policy - url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md + url: https://github.com/netbox-community/netbox/blob/main/CONTRIBUTING.md about: "Please read through our contributing policy before opening an issue or pull request." - name: ❓ Discussion url: https://github.com/netbox-community/netbox/discussions diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aab8bc34f..85070d98e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,11 +3,15 @@ name: CI on: push: paths-ignore: + - '.github/ISSUE_TEMPLATE/**' + - '.github/PULL_REQUEST_TEMPLATE.md' - 'contrib/**' - 'docs/**' - 'netbox/translations/**' pull_request: paths-ignore: + - '.github/ISSUE_TEMPLATE/**' + - '.github/PULL_REQUEST_TEMPLATE.md' - 'contrib/**' - 'docs/**' - 'netbox/translations/**' diff --git a/.github/workflows/close-incomplete-issues.yml b/.github/workflows/close-incomplete-issues.yml index 4d31d735e..1b3adf901 100644 --- a/.github/workflows/close-incomplete-issues.yml +++ b/.github/workflows/close-incomplete-issues.yml @@ -12,6 +12,7 @@ permissions: jobs: stale: + if: github.repository == 'netbox-community/netbox' runs-on: ubuntu-latest steps: - uses: actions/stale@v9 diff --git a/.github/workflows/close-stale-issues.yml b/.github/workflows/close-stale-issues.yml index 1e0e193df..723fd6241 100644 --- a/.github/workflows/close-stale-issues.yml +++ b/.github/workflows/close-stale-issues.yml @@ -13,6 +13,7 @@ permissions: jobs: stale: + if: github.repository == 'netbox-community/netbox' runs-on: ubuntu-latest steps: - uses: actions/stale@v9 @@ -38,7 +39,7 @@ jobs: issues may receive direct feedback. **Do not** attempt to circumvent this 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). + our [contributing guide](https://github.com/netbox-community/netbox/blob/main/CONTRIBUTING.md). # Pull request parameters close-pr-message: > diff --git a/.github/workflows/lock-threads.yml b/.github/workflows/lock-threads.yml index 0f3636784..2148e563b 100644 --- a/.github/workflows/lock-threads.yml +++ b/.github/workflows/lock-threads.yml @@ -13,9 +13,10 @@ permissions: jobs: lock: + if: github.repository == 'netbox-community/netbox' runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v5 + - uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1 with: issue-inactive-days: 90 pr-inactive-days: 30 diff --git a/.github/workflows/update-translation-strings.yml b/.github/workflows/update-translation-strings.yml index e78cd4296..3152232e2 100644 --- a/.github/workflows/update-translation-strings.yml +++ b/.github/workflows/update-translation-strings.yml @@ -13,6 +13,7 @@ env: jobs: makemessages: + if: github.repository == 'netbox-community/netbox' runs-on: ubuntu-latest env: NETBOX_CONFIGURATION: netbox.configuration_testing @@ -47,7 +48,7 @@ jobs: run: python netbox/manage.py makemessages -l ${{ env.LOCALE }} - name: Commit changes - uses: EndBug/add-and-commit@v9 + uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4 with: add: 'netbox/translations/' default_author: github_actions diff --git a/.tx/config b/.tx/config index 342331d4e..b0562b978 100755 --- a/.tx/config +++ b/.tx/config @@ -1,7 +1,7 @@ [main] host = https://app.transifex.com -[o:netbox-community:p:netbox:r:9cbf4fcf95b3d92e4ebbf1a5e5d1caee] +[o:netbox-community:p:netbox:r:034999968a7366ba27a8bdf1ab63bf42] file_filter = netbox/translations//LC_MESSAGES/django.po source_file = netbox/translations/en/LC_MESSAGES/django.po type = PO diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a760b8371..100d996c6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -84,7 +84,7 @@ intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Poli * It's very important that you not submit a pull request until a relevant issue has been opened **and** assigned to you. Otherwise, you risk wasting time on work that may ultimately not be needed. -* New pull requests should generally be based off of the `develop` branch, rather than `master`. The `develop` branch is used for ongoing development, while `master` is used for tracking stable releases. (If you're developing for an upcoming minor release, use `feature` instead.) +* New pull requests should generally be based off of the `main` branch. This branch, in keeping with the [trunk-based development](https://trunkbaseddevelopment.com/) approach, is used for ongoing development and bug fixes and always represents the newest stable code, from which releases are periodically branched. (If you're developing for an upcoming minor release, use `feature` instead.) * In most cases, it is not necessary to add a changelog entry: A maintainer will take care of this when the PR is merged. (This helps avoid merge conflicts resulting from multiple PRs being submitted simultaneously.) diff --git a/README.md b/README.md index e3829c2cc..3a29a6fd2 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@
- NetBox logo + NetBox logo

The cornerstone of every automated network

Latest release - License + License Contributors GitHub stars Languages supported - CI status + CI status

NetBox Community | NetBox Cloud | diff --git a/SECURITY.md b/SECURITY.md index 97881a901..58b73cbb7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,6 +14,12 @@ Administrators are encouraged to adhere to industry best practices concerning th * Prohibit access to your database from clients other than the NetBox application * Keep your deployment updated to the most recent stable release +## Compliance Reporting + +Please note that security compliance reports (e.g. SOC 2) are provided by NetBox Labs only to customers using NetBox Cloud or NetBox Enterprise. They are not available to users of self-hosted NetBox Community Edition. + +If you would like to consider upgrading to NetBox Cloud or Enterprise, please contact `sales@netboxlabs.com`. + ## Reporting a Suspected Vulnerability If you believe you've uncovered a security vulnerability and wish to report it confidentially, you may do so by emailing `security@netboxlabs.com`. Please ensure that your report meets all the following conditions: diff --git a/base_requirements.txt b/base_requirements.txt index 169f4196d..0c6e308e1 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -1,6 +1,6 @@ # The Python web framework on which NetBox is built # https://docs.djangoproject.com/en/stable/releases/ -Django<5.2 +Django==5.2.* # Django middleware which permits cross-domain API requests # https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst @@ -8,8 +8,6 @@ django-cors-headers # Runtime UI tool for debugging Django # https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst -# Pinned for DNS looukp bug; see https://github.com/netbox-community/netbox/issues/16454 -# and https://github.com/jazzband/django-debug-toolbar/issues/1927 django-debug-toolbar # Library for writing reusable URL query filters @@ -44,6 +42,10 @@ django-rich # https://github.com/rq/django-rq/blob/master/CHANGELOG.md django-rq +# Provides a variety of storage backends +# https://github.com/jschneier/django-storages/blob/master/CHANGELOG.rst +django-storages + # Abstraction models for rendering and paginating HTML tables # https://github.com/jieter/django-tables2/blob/master/CHANGELOG.md django-tables2 @@ -80,6 +82,10 @@ gunicorn # https://jinja.palletsprojects.com/changes/ Jinja2 +# JSON schema validation +# https://github.com/python-jsonschema/jsonschema/blob/main/CHANGELOG.rst +jsonschema + # Simple markup language for rendering HTML # https://python-markdown.github.io/changelog/ Markdown @@ -90,7 +96,7 @@ mkdocs-material # Introspection for embedded code # https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md -mkdocstrings[python-legacy] +mkdocstrings[python] # Library for manipulating IP prefixes and addresses # https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst diff --git a/contrib/generated_schema.json b/contrib/generated_schema.json index 639f0df8d..66a61cbad 100644 --- a/contrib/generated_schema.json +++ b/contrib/generated_schema.json @@ -427,6 +427,7 @@ "e3", "xdsl", "docsis", + "moca", "bpon", "epon", "10g-epon", @@ -500,6 +501,9 @@ "n", "mrj21", "fc", + "fc-pc", + "fc-upc", + "fc-apc", "lc", "lc-pc", "lc-upc", @@ -565,6 +569,9 @@ "n", "mrj21", "fc", + "fc-pc", + "fc-upc", + "fc-apc", "lc", "lc-pc", "lc-upc", diff --git a/docs/administration/authentication/overview.md b/docs/administration/authentication/overview.md index e582f009e..19c86a4c0 100644 --- a/docs/administration/authentication/overview.md +++ b/docs/administration/authentication/overview.md @@ -54,6 +54,7 @@ Icons](https://github.com/google/material-design-icons) icon's name; or be `None` for no icon. For instance, the OIDC backend may be customized with + ```python SOCIAL_AUTH_BACKEND_ATTRS = { 'oidc': ("My awesome SSO", "login"), diff --git a/docs/administration/replicating-netbox.md b/docs/administration/replicating-netbox.md index 7cc4d3832..f702c3ffd 100644 --- a/docs/administration/replicating-netbox.md +++ b/docs/administration/replicating-netbox.md @@ -54,7 +54,7 @@ pg_dump --username netbox --password --host localhost -s netbox > netbox_schema. By default, NetBox stores uploaded files (such as image attachments) in its media directory. To fully replicate an instance of NetBox, you'll need to copy both the database and the media files. !!! note - These operations are not necessary if your installation is utilizing a [remote storage backend](../configuration/system.md#storage_backend). + These operations are not necessary if your installation is utilizing a [remote storage backend](../configuration/system.md#storages). ### Archive the Media Directory diff --git a/docs/configuration/default-values.md b/docs/configuration/default-values.md index d90e6eafc..1d1992a8e 100644 --- a/docs/configuration/default-values.md +++ b/docs/configuration/default-values.md @@ -69,7 +69,7 @@ For a complete list of available preferences, log into NetBox and navigate to `/ !!! tip "Dynamic Configuration Parameter" -Default: 50 +Default: `50` The default maximum number of objects to display per page within each list of objects. @@ -79,7 +79,7 @@ The default maximum number of objects to display per page within each list of ob !!! tip "Dynamic Configuration Parameter" -Default: 15 +Default: `15` The default value for the `amperage` field when creating new power feeds. @@ -89,7 +89,7 @@ The default value for the `amperage` field when creating new power feeds. !!! tip "Dynamic Configuration Parameter" -Default: 80 +Default: `80` The default value (percentage) for the `max_utilization` field when creating new power feeds. @@ -99,7 +99,7 @@ The default value (percentage) for the `max_utilization` field when creating new !!! tip "Dynamic Configuration Parameter" -Default: 120 +Default: `120` The default value for the `voltage` field when creating new power feeds. @@ -109,7 +109,7 @@ The default value for the `voltage` field when creating new power feeds. !!! tip "Dynamic Configuration Parameter" -Default: 22 +Default: `22` Default height (in pixels) of a unit within a rack elevation. For best results, this should be approximately one tenth of `RACK_ELEVATION_DEFAULT_UNIT_WIDTH`. @@ -119,6 +119,6 @@ Default height (in pixels) of a unit within a rack elevation. For best results, !!! tip "Dynamic Configuration Parameter" -Default: 220 +Default: `220` Default width (in pixels) of a unit within a rack elevation. diff --git a/docs/configuration/development.md b/docs/configuration/development.md index 6e1a4d9c4..a76fb80b0 100644 --- a/docs/configuration/development.md +++ b/docs/configuration/development.md @@ -2,7 +2,7 @@ ## DEBUG -Default: False +Default: `False` This setting enables debugging. Debugging should be enabled only during development or troubleshooting. Note that only clients which access NetBox from a recognized [internal IP address](./system.md#internal_ips) will see debugging tools in the user @@ -16,6 +16,6 @@ interface. ## DEVELOPER -Default: False +Default: `False` This parameter serves as a safeguard to prevent some potentially dangerous behavior, such as generating new database schema migrations. Additionally, enabling this setting disables the debug warning banner in the UI. Set this to `True` **only** if you are actively developing the NetBox code base. diff --git a/docs/configuration/error-reporting.md b/docs/configuration/error-reporting.md index 56f187845..3b86a78d2 100644 --- a/docs/configuration/error-reporting.md +++ b/docs/configuration/error-reporting.md @@ -2,7 +2,7 @@ ## SENTRY_DSN -Default: None +Default: `None` Defines a Sentry data source name (DSN) for automated error reporting. `SENTRY_ENABLED` must be True for this parameter to take effect. For example: @@ -14,7 +14,7 @@ SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0" ## SENTRY_ENABLED -Default: False +Default: `False` Set to True to enable automatic error reporting via [Sentry](https://sentry.io/). @@ -25,7 +25,7 @@ Set to True to enable automatic error reporting via [Sentry](https://sentry.io/) ## SENTRY_SAMPLE_RATE -Default: 1.0 (all) +Default: `1.0` (all) The sampling rate for errors. Must be a value between 0 (disabled) and 1.0 (report on all errors). @@ -33,7 +33,7 @@ The sampling rate for errors. Must be a value between 0 (disabled) and 1.0 (repo ## SENTRY_SEND_DEFAULT_PII -Default: False +Default: `False` Maps to the Sentry SDK's [`send_default_pii`](https://docs.sentry.io/platforms/python/configuration/options/#send-default-pii) parameter. If enabled, certain personally identifiable information (PII) is added. @@ -60,7 +60,7 @@ SENTRY_TAGS = { ## SENTRY_TRACES_SAMPLE_RATE -Default: 0 (disabled) +Default: `0` (disabled) The sampling rate for transactions. Must be a value between 0 (disabled) and 1.0 (report on all transactions). diff --git a/docs/configuration/graphql-api.md b/docs/configuration/graphql-api.md index a792da544..9b02d745c 100644 --- a/docs/configuration/graphql-api.md +++ b/docs/configuration/graphql-api.md @@ -4,7 +4,7 @@ !!! tip "Dynamic Configuration Parameter" -Default: True +Default: `True` Setting this to False will disable the GraphQL API. @@ -12,6 +12,6 @@ Setting this to False will disable the GraphQL API. ## GRAPHQL_MAX_ALIASES -Default: 10 +Default: `10` The maximum number of queries that a GraphQL API request may contain. diff --git a/docs/configuration/miscellaneous.md b/docs/configuration/miscellaneous.md index c14c0ac77..e4d46f428 100644 --- a/docs/configuration/miscellaneous.md +++ b/docs/configuration/miscellaneous.md @@ -55,7 +55,7 @@ Sets content for the top banner in the user interface. ## CENSUS_REPORTING_ENABLED -Default: True +Default: `True` Enables anonymous census reporting. To opt out of census reporting, set this to False. @@ -67,7 +67,7 @@ This data enables the project maintainers to estimate how many NetBox deployment !!! tip "Dynamic Configuration Parameter" -Default: 90 +Default: `90` The number of days to retain logged changes (object creations, updates, and deletions). Set this to `0` to retain changes in the database indefinitely. @@ -79,7 +79,7 @@ changes in the database indefinitely. ## CHANGELOG_SKIP_EMPTY_CHANGES -Default: True +Default: `True` If enabled, a change log record will not be created when an object is updated without any changes to its existing field values. @@ -100,7 +100,7 @@ The maximum size (in bytes) of an incoming HTTP request (i.e. `GET` or `POST` da !!! tip "Dynamic Configuration Parameter" -Default: True +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. @@ -128,7 +128,7 @@ The maximum amount (in bytes) of uploaded data that will be held in memory befor !!! tip "Dynamic Configuration Parameter" -Default: 90 +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. @@ -141,7 +141,7 @@ The number of days to retain job results (scripts and reports). Set this to `0` !!! tip "Dynamic Configuration Parameter" -Default: False +Default: `False` Setting this to True will display a "maintenance mode" banner at the top of every page. Additionally, NetBox will no longer update a user's "last active" time upon login. This is to allow new logins when the database is in a read-only state. Recording of login times will resume when maintenance mode is disabled. @@ -161,7 +161,7 @@ This specifies the URL to use when presenting a map of a physical location by st !!! tip "Dynamic Configuration Parameter" -Default: 1000 +Default: `1000` A web user or API consumer can request an arbitrary number of objects by appending the "limit" parameter to the URL (e.g. `?limit=1000`). This parameter defines the maximum acceptable limit. Setting this to `0` or `None` will allow a client to retrieve _all_ matching objects at once with no limit by specifying `?limit=0`. @@ -169,7 +169,7 @@ A web user or API consumer can request an arbitrary number of objects by appendi ## METRICS_ENABLED -Default: False +Default: `False` Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Prometheus Metrics](../integrations/prometheus-metrics.md) documentation for more details. @@ -179,7 +179,7 @@ Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Pr !!! tip "Dynamic Configuration Parameter" -Default: False +Default: `False` When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to prefer IPv4 instead. @@ -203,7 +203,7 @@ If no queue is defined the queue named `default` will be used. ## RELEASE_CHECK_URL -Default: None (disabled) +Default: `None` (disabled) This parameter defines the URL of the repository that will be checked for new NetBox releases. When a new release is detected, a message will be displayed to administrative users on the home page. This can be set to the official repository (`'https://api.github.com/repos/netbox-community/netbox/releases'`) or a custom fork. Set this to `None` to disable automatic update checks. @@ -233,3 +233,15 @@ This parameter controls how frequently a failed job is retried, up to the maximu Default: `0` (retries disabled) The maximum number of times a background task will be retried before being marked as failed. + +## DISK_BASE_UNIT + +Default: `1000` + +The base unit for disk sizes. Set this to `1024` to use binary prefixes (MiB, GiB, etc.) instead of decimal prefixes (MB, GB, etc.). + +## RAM_BASE_UNIT + +Default: `1000` + +The base unit for RAM sizes. Set this to `1024` to use binary prefixes (MiB, GiB, etc.) instead of decimal prefixes (MB, GB, etc.). diff --git a/docs/configuration/plugins.md b/docs/configuration/plugins.md index a3e691f63..9e19622f9 100644 --- a/docs/configuration/plugins.md +++ b/docs/configuration/plugins.md @@ -2,7 +2,7 @@ ## PLUGINS -Default: Empty +Default: `[]` A list of installed [NetBox plugins](../plugins/index.md) to enable. Plugins will not take effect unless they are listed here. @@ -13,7 +13,7 @@ A list of installed [NetBox plugins](../plugins/index.md) to enable. Plugins wil ## PLUGINS_CONFIG -Default: Empty +Default: `[]` This parameter holds configuration settings for individual NetBox plugins. It is defined as a dictionary, with each key using the name of an installed plugin. The specific parameters supported are unique to each plugin: Reference the plugin's documentation to determine the supported parameters. An example configuration is shown below: @@ -33,3 +33,21 @@ Note that a plugin must be listed in `PLUGINS` for its configuration to take eff --- +## PLUGINS_CATALOG_CONFIG + +Default: Empty + +This parameter controls how individual plugins are displayed in the plugins catalog under Admin > System > Plugins. Adding a plugin to the `hidden` list will omit that plugin from the catalog. Adding a plugin to the `static` list will display the plugin, but not link to the plugin details or upgrade instructions. + +An example configuration is shown below: + +```python +PLUGINS_CATALOG_CONFIG = { + 'hidden': [ + 'plugin1', + ], + 'static': [ + 'plugin2', + ], +} +``` diff --git a/docs/configuration/required-parameters.md b/docs/configuration/required-parameters.md index 90eb8c0cf..4a18e8a6c 100644 --- a/docs/configuration/required-parameters.md +++ b/docs/configuration/required-parameters.md @@ -2,7 +2,7 @@ ## ALLOWED_HOSTS -This is a list of valid fully-qualified domain names (FQDNs) and/or IP addresses that can be used to reach the NetBox service. Usually this is the same as the hostname for the NetBox server, but can also be different; for example, when using a reverse proxy serving the NetBox website under a different FQDN than the hostname of the NetBox server. To help guard against [HTTP Host header attackes](https://docs.djangoproject.com/en/3.0/topics/security/#host-headers-virtual-hosting), NetBox will not permit access to the server via any other hostnames (or IPs). +This is a list of valid fully-qualified domain names (FQDNs) and/or IP addresses that can be used to reach the NetBox service. Usually this is the same as the hostname for the NetBox server, but can also be different; for example, when using a reverse proxy serving the NetBox website under a different FQDN than the hostname of the NetBox server. To help guard against [HTTP Host header attacks](https://docs.djangoproject.com/en/3.0/topics/security/#host-headers-virtual-hosting), NetBox will not permit access to the server via any other hostnames (or IPs). !!! note This parameter must always be defined as a list or tuple, even if only a single value is provided. @@ -25,7 +25,30 @@ ALLOWED_HOSTS = ['*'] ## DATABASE -NetBox requires access to a PostgreSQL 12 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary: +!!! warning "Legacy Configuration Parameter" + The `DATABASE` configuration parameter is deprecated and will be removed in a future release. Users are advised to adopt the new `DATABASES` (plural) parameter, which allows for the configuration of multiple databases. + +See the [`DATABASES`](#databases) configuration below for usage. + +--- + +## DATABASES + +!!! info "This parameter was introduced in NetBox v4.3." + +NetBox requires access to a PostgreSQL 14 or later database service to store data. This service can run locally on the NetBox server or on a remote system. Databases are defined as named dictionaries: + +```python +DATABASES = { + 'default': {...}, + 'external1': {...}, + 'external2': {...}, +} +``` + +NetBox itself requires only that a `default` database is defined. However, certain plugins may require the configuration of additional databases. (Consider also configuring the [`DATABASE_ROUTERS`](./system.md#database_routers) parameter when multiple databases are in use.) + +The following parameters must be defined for each database: * `NAME` - Database name * `USER` - PostgreSQL username @@ -38,14 +61,16 @@ NetBox requires access to a PostgreSQL 12 or later database service to store dat Example: ```python -DATABASE = { - 'ENGINE': 'django.db.backends.postgresql', - 'NAME': 'netbox', # Database name - 'USER': 'netbox', # PostgreSQL username - 'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password - 'HOST': 'localhost', # Database server - 'PORT': '', # Database port (leave blank for default) - 'CONN_MAX_AGE': 300, # Max database connection age +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'netbox', # Database name + 'USER': 'netbox', # PostgreSQL username + 'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password + 'HOST': 'localhost', # Database server + 'PORT': '', # Database port (leave blank for default) + 'CONN_MAX_AGE': 300, # Max database connection age + } } ``` @@ -53,7 +78,7 @@ DATABASE = { NetBox supports all PostgreSQL database options supported by the underlying Django framework. For a complete list of available parameters, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#databases). !!! warning - Make sure to use a PostgreSQL-compatible backend for the ENGINE setting. If you don't specify an ENGINE, the default will be django.db.backends.postgresql. + The `ENGINE` parameter must specify a PostgreSQL-compatible database backend. If not defined, the default engine `django.db.backends.postgresql` will be used. --- diff --git a/docs/configuration/security.md b/docs/configuration/security.md index b97f31432..771eba5c5 100644 --- a/docs/configuration/security.md +++ b/docs/configuration/security.md @@ -2,7 +2,10 @@ ## ALLOW_TOKEN_RETRIEVAL -Default: True +Default: `False` + +!!! note + The default value of this parameter changed from true to false in NetBox v4.3.0. If disabled, the values of API tokens will not be displayed after each token's initial creation. A user **must** record the value of a token prior to its creation, or it will be lost. Note that this affects _all_ users, regardless of assigned permissions. @@ -47,7 +50,7 @@ Although it is not recommended, the default validation rules can be disabled by ## CORS_ORIGIN_ALLOW_ALL -Default: False +Default: `False` If True, cross-origin resource sharing (CORS) requests will be accepted from all origins. If False, a whitelist will be used (see below). @@ -79,7 +82,7 @@ The name of the cookie to use for the cross-site request forgery (CSRF) authenti ## CSRF_COOKIE_SECURE -Default: False +Default: `False` If true, the cookie employed for cross-site request forgery (CSRF) protection will be marked as secure, meaning that it can only be sent across an HTTPS connection. @@ -159,7 +162,7 @@ EXEMPT_VIEW_PERMISSIONS = ['*'] ## LOGIN_PERSISTENCE -Default: False +Default: `False` If true, the lifetime of a user's authentication session will be automatically reset upon each valid request. For example, if [`LOGIN_TIMEOUT`](#login_timeout) is configured to 14 days (the default), and a user whose session is due to expire in five days makes a NetBox request (with a valid session cookie), the session's lifetime will be reset to 14 days. @@ -169,7 +172,7 @@ Note that enabling this setting causes NetBox to update a user's session in the ## LOGIN_REQUIRED -Default: True +Default: `True` When enabled, only authenticated users are permitted to access any part of NetBox. Disabling this will allow unauthenticated users to access most areas of NetBox (but not make any changes). @@ -180,12 +183,23 @@ When enabled, only authenticated users are permitted to access any part of NetBo ## LOGIN_TIMEOUT -Default: 1209600 seconds (14 days) +Default: `1209600` seconds (14 days) The lifetime (in seconds) of the authentication cookie issued to a NetBox user upon login. --- +## LOGIN_FORM_HIDDEN + +Default: False + +Option to hide the login form when only SSO authentication is in use. + +!!! warning + If the SSO provider is unreachable, login to NetBox will be impossible if this option is enabled. The only recourse is to disable it in the local configuration and restart the NetBox service. + +--- + ## LOGOUT_REDIRECT_URL Default: `'home'` @@ -196,7 +210,7 @@ The view name or URL to which a user is redirected after logging out. ## SECURE_HSTS_INCLUDE_SUBDOMAINS -Default: False +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. @@ -204,7 +218,7 @@ If true, the `includeSubDomains` directive will be included in the HTTP Strict T ## SECURE_HSTS_PRELOAD -Default: False +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. @@ -212,7 +226,7 @@ If true, the `preload` directive will be included in the HTTP Strict Transport S ## SECURE_HSTS_SECONDS -Default: 0 +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. @@ -220,7 +234,7 @@ If set to a non-zero integer value, the SecurityMiddleware sets the HTTP Strict ## SECURE_SSL_REDIRECT -Default: False +Default: `False` If true, all non-HTTPS requests will be automatically redirected to use HTTPS. @@ -239,7 +253,7 @@ The name used for the session cookie. See the [Django documentation](https://doc ## SESSION_COOKIE_SECURE -Default: False +Default: `False` If true, the cookie employed for session authentication will be marked as secure, meaning that it can only be sent across an HTTPS connection. @@ -247,6 +261,6 @@ If true, the cookie employed for session authentication will be marked as secure ## SESSION_FILE_PATH -Default: None +Default: `None` HTTP session data is used to track authenticated users when they access NetBox. By default, NetBox stores session data in its PostgreSQL database. However, this inhibits authentication to a standby instance of NetBox without write access to the database. Alternatively, a local file path may be specified here and NetBox will store session data as files instead of using the database. Note that the NetBox system user must have read and write permissions to this path. diff --git a/docs/configuration/system.md b/docs/configuration/system.md index af3a6f5e6..fe01e40b1 100644 --- a/docs/configuration/system.md +++ b/docs/configuration/system.md @@ -2,7 +2,7 @@ ## BASE_PATH -Default: None +Default: `None` The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at https://example.com/netbox/, set: @@ -12,6 +12,16 @@ BASE_PATH = 'netbox/' --- +## DATABASE_ROUTERS + +!!! info "This parameter was introduced in NetBox v4.3." + +Default: `[]` (empty list) + +An iterable of [database routers](https://docs.djangoproject.com/en/stable/topics/db/multi-db/) to use for automatically selecting the appropriate database(s) for a query. This is useful only when [multiple databases](./required-parameters.md#databases) have been configured. + +--- + ## DEFAULT_LANGUAGE Default: `en-us` (US English) @@ -64,7 +74,7 @@ Email is sent from NetBox only for critical events or if configured for [logging ## HTTP_PROXIES -Default: None +Default: `None` A dictionary of HTTP proxies to use for outbound requests originating from NetBox (e.g. when sending webhook requests). Proxies should be specified by schema (HTTP and HTTPS) as per the [Python requests library documentation](https://requests.readthedocs.io/en/latest/user/advanced/#proxies). For example: @@ -75,6 +85,8 @@ HTTP_PROXIES = { } ``` +If more flexibility is needed in determining which proxy to use for a given request, consider implementing one or more custom proxy routers via the [`PROXY_ROUTERS`](#proxy_routers) parameter. + --- ## INTERNAL_IPS @@ -89,7 +101,7 @@ addresses (and [`DEBUG`](./development.md#debug) is true). ## ISOLATED_DEPLOYMENT -Default: False +Default: `False` Set this configuration parameter to True for NetBox deployments which do not have Internet access. This will disable miscellaneous functionality which depends on access to the Internet. @@ -160,6 +172,18 @@ The file path to the location where media files (such as image attachments) are --- +## PROXY_ROUTERS + +!!! info "This parameter was introduced in NetBox v4.3." + +Default: `["utilities.proxy.DefaultProxyRouter"]` + +A list of Python classes responsible for determining which proxy server(s) to use for outbound HTTP requests. Each item in the list can be the class itself or the dotted path to the class. + +The `route()` method on each class must return a dictionary of candidate proxies arranged by protocol (e.g. `http` and/or `https`), or None if no viable proxy can be determined. The default class, `DefaultProxyRouter`, simply returns the content of [`HTTP_PROXIES`](#http_proxies). + +--- + ## REPORTS_ROOT Default: `$INSTALL_ROOT/netbox/reports/` @@ -184,29 +208,52 @@ The dotted path to the desired search backend class. `CachedValueSearchBackend` --- -## STORAGE_BACKEND +## STORAGES -Default: None (local storage) +The backend storage engine for handling uploaded files such as [image attachments](../models/extras/imageattachment.md) and [custom scripts](../customization/custom-scripts.md). NetBox integrates with the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) and [`django-storage-swift`](https://github.com/dennisv/django-storage-swift) libraries, which provide backends for several popular file storage services. If not configured, local filesystem storage will be used. -The backend storage engine for handling uploaded files (e.g. image attachments). NetBox supports integration with the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) and [`django-storage-swift`](https://github.com/dennisv/django-storage-swift) packages, which provide backends for several popular file storage services. If not configured, local filesystem storage will be used. +By default, the following configuration is used: -The configuration parameters for the specified storage backend are defined under the `STORAGE_CONFIG` setting. +```python +STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, + "scripts": { + "BACKEND": "extras.storage.ScriptFileSystemStorage", + }, +} +``` ---- +Within the `STORAGES` dictionary, `"default"` is used for image uploads, "staticfiles" is for static files and `"scripts"` is used for custom scripts. -## STORAGE_CONFIG +If using a remote storage like S3, define the config as `STORAGES[key]["OPTIONS"]` for each storage item as needed. For example: -Default: Empty +```python +STORAGES = { + "scripts": { + "BACKEND": "storages.backends.s3boto3.S3Boto3Storage", + "OPTIONS": { + 'access_key': 'access key', + 'secret_key': 'secret key', + } + }, +} +``` -A dictionary of configuration parameters for the storage backend configured as `STORAGE_BACKEND`. The specific parameters to be used here are specific to each backend; see the documentation for your selected backend ([`django-storages`](https://django-storages.readthedocs.io/en/stable/) or [`django-storage-swift`](https://github.com/dennisv/django-storage-swift)) for more detail. +The specific configuration settings for each storage backend can be found in the [django-storages documentation](https://django-storages.readthedocs.io/en/latest/index.html). -If `STORAGE_BACKEND` is not defined, this setting will be ignored. +!!! note + Any keys defined in the `STORAGES` configuration parameter replace those in the default configuration. It is only necessary to define keys within the `STORAGES` for the specific backend(s) you wish to configure. --- ## TIME_ZONE -Default: UTC +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). @@ -214,6 +261,6 @@ The time zone NetBox will use when dealing with dates and times. It is recommend ## TRANSLATION_ENABLED -Default: True +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-links.md b/docs/customization/custom-links.md index baae1db4f..265efe669 100644 --- a/docs/customization/custom-links.md +++ b/docs/customization/custom-links.md @@ -2,7 +2,7 @@ Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside NetBox. For example, you might create a custom link on the device view which links to the current device in a Network Monitoring System (NMS). -Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link has display text and a URL, and data from the NetBox item being viewed can be included in the link using [Jinja2 template code](https://jinja2docs.readthedocs.io/en/stable/) through the variable `object`, and custom fields through `object.cf`. +Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link has display text and a URL, and data from the NetBox item being viewed can be included in the link using [Jinja template code](https://jinja.palletsprojects.com/en/stable/) through the variable `object`, and custom fields through `object.cf`. For example, you might define a link like this: diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index 1051b31f6..e7536a654 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -140,6 +140,8 @@ The Script class provides two convenience methods for reading data from files: These two methods will load data in YAML or JSON format, respectively, from files within the local path (i.e. `SCRIPTS_ROOT`). +**Note:** These convenience methods are deprecated and will be removed in NetBox v4.4. These only work if running scripts within the local path, they will not work if using a storage other than ScriptFileSystemStorage. + ## Logging The Script object provides a set of convenient functions for recording messages at different severity levels: @@ -308,6 +310,7 @@ A particular object within NetBox. Each ObjectVar must specify a particular mode * `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 `

- NetBox logo + NetBox logo
diff --git a/foo.py b/foo.py new file mode 100644 diff --git a/docs/development/index.md b/docs/development/index.md index 0d570abe6..09489ebdd 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -8,11 +8,10 @@ NetBox and many of its related projects are maintained on [GitHub](https://githu ![GitHub](../media/development/github.png) -There are three permanent branches in the repository: +There are two permanent branches in the repository: -* `master` - The current stable release. Individual changes should never be pushed directly to this branch, but rather merged from `develop`. -* `develop` - Active development for the upcoming patch release. Pull requests will typically be based on this branch unless they introduce breaking changes that must be deferred until the next minor release. -* `feature` - New feature work to be introduced in the next minor release (e.g. from v3.3 to v3.4). +* `main` - Active development for the upcoming patch release. Pull requests will typically be based on this branch unless they introduce breaking changes that must be deferred until the next minor release. +* `feature` - New feature work to be introduced in the next minor release (e.g. from v4.2 to v4.3). NetBox components are arranged into Django apps. Each app holds the models, views, and other resources relevant to a particular function: @@ -57,4 +56,4 @@ NetBox follows the [benevolent dictator](http://oss-watch.ac.uk/resources/benevo ## Licensing -The entire NetBox project is licensed as open source under the [Apache 2.0 license](https://github.com/netbox-community/netbox/blob/master/LICENSE.txt). This is a very permissive license which allows unlimited redistribution of all code within the project. Note that all submissions to the project are subject to the same license. +The entire NetBox project is licensed as open source under the [Apache 2.0 license](https://github.com/netbox-community/netbox/blob/main/LICENSE.txt). This is a very permissive license which allows unlimited redistribution of all code within the project. Note that all submissions to the project are subject to the same license. diff --git a/docs/development/release-checklist.md b/docs/development/release-checklist.md index 4e5fdeca8..e48cb140e 100644 --- a/docs/development/release-checklist.md +++ b/docs/development/release-checklist.md @@ -1,12 +1,14 @@ # Release Checklist -This documentation describes the process of packaging and publishing a new NetBox release. There are three types of release: +This documentation describes the process of packaging and publishing a new NetBox release. There are three types of releases: * Major release (e.g. v3.7.8 to v4.0.0) * Minor release (e.g. v4.0.10 to v4.1.0) * Patch release (e.g. v4.1.0 to v4.1.1) -While major releases generally introduce some very substantial change to the application, they are typically treated the same as minor version increments for the purpose of release packaging. +While major releases generally introduce some very substantial changes to the application, they are typically treated the same as minor version increments for the purpose of release packaging. + +For patch releases (e.g. upgrading from v4.2.2 to v4.2.3), begin at the [patch releases](#patch-releases) heading below. For minor or major releases, complete the entire checklist. ## Minor Version Releases @@ -29,6 +31,29 @@ Close the [release milestone](https://github.com/netbox-community/netbox/milesto Check that a link to the release notes for the new version is present in the navigation menu (defined in `mkdocs.yml`), and that a summary of all major new features has been added to `docs/index.md`. +### Update the Dependency Requirements Matrix + +For every minor release, update the dependency requirements matrix in `docs/installation/upgrading.md` ("All versions") to reflect the supported versions of Python, PostgreSQL, and Redis: + +1. Add a new row with the supported dependency versions. +2. Include a documentation link using the release tag format: `https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md` +3. Bold any version changes for clarity. + +**Example Update:** + +```markdown +| NetBox Version | Python min | Python max | PostgreSQL min | Redis min | Documentation | +|:--------------:|:----------:|:----------:|:--------------:|:---------:|:-------------------------------------------------------------------------------------------------:| +| 4.2 | 3.10 | 3.12 | **13** | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md) | +``` + +### Update System Requirements + +If a new Django release is adopted or other major dependencies (Python, PostgreSQL, Redis) change: + +* Update the installation guide (`docs/installation/index.md`) with the new minimum versions. +* Update the upgrade guide (`docs/installation/upgrading.md`) for the current version accordingly. + ### Manually Perform a New Install Start the documentation server and navigate to the current version of the installation docs: @@ -37,15 +62,25 @@ Start the documentation server and navigate to the current version of the instal mkdocs serve ``` -Follow these instructions to perform a new installation of NetBox in a temporary environment. This process must not be automated: The goal of this step is to catch any errors or omissions in the documentation, and ensure that it is kept up-to-date for each release. Make any necessary changes to the documentation before proceeding with the release. +Follow these instructions to perform a new installation of NetBox in a temporary environment. This process must not be automated: The goal of this step is to catch any errors or omissions in the documentation and ensure that it is kept up to date for each release. Make any necessary changes to the documentation before proceeding with the release. ### Test Upgrade Paths -Upgrading from a previous version typically involves database migrations, which must work without errors. Supported upgrade paths include from one minor version to another within the same major version (i.e. 4.0 to 4.1), as well as from the latest patch version of the previous minor version (i.e. 3.7 to 4.0 or to 4.1). Prior to release, test all these supported paths by loading demo data from the source version and performing a `./manage.py migrate`. +Upgrading from a previous version typically involves database migrations, which must work without errors. +Test the following supported upgrade paths: -### Merge the Release Branch +- From one minor version to another within the same major version (e.g. 4.0 to 4.1). +- From the latest patch version of the previous minor version (e.g. 3.7 to 4.0 or 4.1). -Submit a pull request to merge the `feature` branch into the `develop` branch in preparation for its release. Once it has been merged, continue with the section for patch releases below. +Prior to release, test all these supported paths by loading demo data from the source version and performing: + +```no-highlight +./manage.py migrate +``` + +### Merge the `feature` Branch + +Submit a pull request to merge the `feature` branch into the `main` branch in preparation for its release. Once it has been merged, continue with the section for the patch releases below. ### Rebuild Demo Data (After Release) @@ -55,6 +90,15 @@ After the release of a new minor version, generate a new demo data snapshot comp ## Patch Releases +### Create a Release Branch + +Begin by creating a new branch (based on `main`) to effect the release. This will comprise the changes listed below. + +``` +git checkout main +git checkout -B release-vX.Y.Z +``` + ### Notify netbox-docker Project of Any Relevant Changes Notify the [`netbox-docker`](https://github.com/netbox-community/netbox-docker) maintainers (in **#netbox-docker**) of any changes that may be relevant to their build process, including: @@ -76,7 +120,20 @@ In cases where upgrading a dependency to its most recent release is breaking, it ### Update UI Dependencies -Check whether any UI dependencies (JavaScript packages, fonts, etc.) need to be updated by running `yarn outdated` from within the `project-static/` directory. [Upgrade these dependencies](./web-ui.md#updating-dependencies) as necessary, then run `yarn bundle` to generate the necessary files for distribution. +Check whether any UI dependencies (JavaScript packages, fonts, etc.) need to be updated by running `yarn outdated` from within the `project-static/` directory. [Upgrade these dependencies](./web-ui.md#updating-dependencies) as necessary, then run `yarn bundle` to generate the necessary files for distribution: + +``` +$ yarn bundle +yarn run v1.22.19 +$ node bundle.js +✅ Bundled source file 'styles/external.scss' to 'netbox-external.css' +✅ Bundled source file 'styles/netbox.scss' to 'netbox.css' +✅ Bundled source file 'styles/svg/rack_elevation.scss' to 'rack_elevation.css' +✅ Bundled source file 'styles/svg/cable_trace.scss' to 'cable_trace.css' +✅ Bundled source file 'index.ts' to 'netbox.js' +✅ Copied graphiql files +Done in 1.00s. +``` ### Rebuild the Device Type Definition Schema @@ -93,7 +150,7 @@ This will automatically update the schema file at `contrib/generated_schema.json Updated language translations should be pulled from [Transifex](https://app.transifex.com/netbox-community/netbox/dashboard/) and re-compiled for each new release. First, retrieve any updated translation files using the Transifex CLI client: ```no-highlight -tx pull +tx pull --force ``` Then, compile these portable (`.po`) files for use in the application: @@ -107,29 +164,29 @@ Then, compile these portable (`.po`) files for use in the application: ### Update Version and Changelog -* Update the version and published date in `release.yaml` with the current version & date. Add a designation (e.g.g `beta1`) if applicable. +* Update the version number and date in `netbox/release.yaml`. Add or remove the designation (e.g. `beta1`) if applicable. * Update the example version numbers in the feature request and bug report templates under `.github/ISSUE_TEMPLATES/`. -* Replace the "FUTURE" placeholder in the release notes with the current date. +* Add a section for this release at the top of the changelog page for the minor version (e.g. `docs/release-notes/version-4.2.md`) listing all relevant changes made in this release. -Commit these changes to the `develop` branch and push upstream. - -### Verify CI Build Status - -Ensure that continuous integration testing on the `develop` branch is completing successfully. If it fails, take action to correct the failure before proceeding with the release. +!!! tip + Put yourself in the shoes of the user when recording change notes. Focus on the effect that each change has for the end user, rather than the specific bits of code that were modified in a PR. Ensure that each message conveys meaning absent context of the initial feature request or bug report. Remember to include keywords or phrases (such as exception names) that can be easily searched. ### Submit a Pull Request -Submit a pull request titled **"Release vX.Y.Z"** to merge the `develop` branch into `master`. Copy the documented release notes into the pull request's body. +Commit the above changes and submit a pull request titled **"Release vX.Y.Z"** to merge the current release branch (e.g. `release-vX.Y.Z`) into `main`. Copy the documented release notes into the pull request's body. -Once CI has completed on the PR, merge it. This effects a new release in the `master` branch. +Once CI has completed and a colleague has reviewed the PR, merge it. This effects a new release in the `main` branch. + +!!! warning + To ensure a streamlined review process, the pull request for a release **must** be limited to the changes outlined in this document. A release PR must never include functional changes to the application: Any unrelated "cleanup" needs to be captured in a separate PR prior to the release being shipped. ### Create a New Release Create a [new release](https://github.com/netbox-community/netbox/releases/new) on GitHub with the following parameters. -* **Tag:** Current version (e.g. `v3.3.1`) -* **Target:** `master` -* **Title:** Version and date (e.g. `v3.3.1 - 2022-08-25`) +* **Tag:** Current version (e.g. `v4.2.1`) +* **Target:** `main` +* **Title:** Version and date (e.g. `v4.2.1 - 2025-01-17`) * **Description:** Copy from the pull request body, then promote the `###` headers to `##` ones Once created, the release will become available for users to install. diff --git a/docs/development/style-guide.md b/docs/development/style-guide.md index 9d6630de0..d7a7ad9da 100644 --- a/docs/development/style-guide.md +++ b/docs/development/style-guide.md @@ -22,7 +22,7 @@ NetBox generally follows the [Django style guide](https://docs.djangoproject.com ### Linting -The [ruff](https://docs.astral.sh/ruff/) linter is used to enforce code style. A [pre-commit hook](./getting-started.md#3-enable-pre-commit-hooks) which runs this automatically is included with NetBox. To invoke `ruff` manually, run: +The [ruff](https://docs.astral.sh/ruff/) linter is used to enforce code style, and is run automatically by [pre-commit](./getting-started.md#5-install-pre-commit). To invoke `ruff` manually, run: ``` ruff check netbox/ diff --git a/docs/development/translations.md b/docs/development/translations.md index 43733c6d1..d00d2583c 100644 --- a/docs/development/translations.md +++ b/docs/development/translations.md @@ -14,10 +14,10 @@ To update the English `.po` file from which all translations are derived, use th ./manage.py makemessages -l en -i "project-static/*" ``` -Then, commit the change and push to the `develop` branch on GitHub. Any new strings will appear for translation on Transifex automatically. +Then, commit the change and push to the `main` branch on GitHub. Any new strings will appear for translation on Transifex automatically. !!! note - It is typically not necessary to update source strings manually, as this is done nightly by a [GitHub action](https://github.com/netbox-community/netbox/blob/develop/.github/workflows/update-translation-strings.yml). + It is typically not necessary to update source strings manually, as this is done nightly by a [GitHub action](https://github.com/netbox-community/netbox/blob/main/.github/workflows/update-translation-strings.yml). ## Updating Translated Strings @@ -30,13 +30,13 @@ To download translated strings automatically, you'll need to: 1. Install the [Transifex CLI client](https://github.com/transifex/cli) 2. Generate a [Transifex API token](https://app.transifex.com/user/settings/api/) -Once you have the client set up, run the following command: +Once you have the client set up, run the following command from the project root (e.g. `/opt/netbox/`): ```no-highlight -TX_TOKEN=$TOKEN tx pull +TX_TOKEN=$TOKEN tx pull --force ``` -This will download all portable (`.po`) translation files from Transifex, updating them locally as needed. +This will download all portable (`.po`) translation files from Transifex, updating them locally as needed. (The `--force` argument instructs the client to disregard the timestamps of local translation files.) Once retrieved, the updated strings need to be compiled into new `.mo` files so they can be used by the application. Run Django's [`compilemessages`](https://docs.djangoproject.com/en/stable/ref/django-admin/#django-admin-compilemessages) management command to compile them: @@ -46,6 +46,9 @@ Once retrieved, the updated strings need to be compiled into new `.mo` files so Once any new `.mo` files have been generated, they need to be committed and pushed back up to GitHub. (Again, this is typically done as part of publishing a new NetBox release.) +!!! tip + Run `git status` to check that both `*.mo` & `*.po` files have been updated as expected. + ## Proposing New Languages If you'd like to add support for a new language to NetBox, the first step is to [submit a GitHub issue](https://github.com/netbox-community/netbox/issues/new?assignees=&labels=type%3A+translation&projects=&template=translation.yaml) to capture the proposal. While we'd like to add as many languages as possible, we do need to limit the rate at which new languages are added. New languages will be selected according to community interest and the number of volunteers who sign up as translators. diff --git a/docs/features/facilities.md b/docs/features/facilities.md index 4c8dfe265..1421281eb 100644 --- a/docs/features/facilities.md +++ b/docs/features/facilities.md @@ -46,7 +46,7 @@ Regions will always be listed alphabetically by name within each parent, and the Like regions, site groups can be arranged in a recursive hierarchy for grouping sites. However, whereas regions are intended for geographic organization, site groups may be used for functional grouping. For example, you might classify sites as corporate, branch, or customer sites in addition to where they are physically located. -The use of both regions and site groups affords to independent but complementary dimensions across which sites can be organized. +The use of both regions and site groups affords two independent but complementary dimensions across which sites can be organized. ## Sites diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md index 9d30f4514..0d74eea05 100644 --- a/docs/installation/1-postgresql.md +++ b/docs/installation/1-postgresql.md @@ -2,39 +2,17 @@ This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md). -!!! warning "PostgreSQL 12 or later required" - NetBox requires PostgreSQL 12 or later. Please note that MySQL and other relational databases are **not** supported. +!!! warning "PostgreSQL 14 or later required" + NetBox requires PostgreSQL 14 or later. Please note that MySQL and other relational databases are **not** supported. ## Installation -=== "Ubuntu" +```no-highlight +sudo apt update +sudo apt install -y postgresql +``` - ```no-highlight - sudo apt update - sudo apt install -y postgresql - ``` - -=== "CentOS" - - ```no-highlight - sudo yum install -y postgresql-server - sudo postgresql-setup --initdb - ``` - - CentOS configures ident host-based authentication for PostgreSQL by default. Because NetBox will need to authenticate using a username and password, modify `/var/lib/pgsql/data/pg_hba.conf` to support MD5 authentication by changing `ident` to `md5` for the lines below: - - ```no-highlight - host all all 127.0.0.1/32 md5 - host all all ::1/128 md5 - ``` - - Once PostgreSQL has been installed, start the service and enable it to run at boot: - - ```no-highlight - sudo systemctl enable --now postgresql - ``` - -Before continuing, verify that you have installed PostgreSQL 12 or later: +Before continuing, verify that you have installed PostgreSQL 14 or later: ```no-highlight psql -V @@ -62,6 +40,9 @@ GRANT CREATE ON SCHEMA public TO netbox; !!! danger "Use a strong password" **Do not use the password from the example.** Choose a strong, random password to ensure secure database authentication for your NetBox installation. +!!! danger "Use UTF8 encoding" + Make sure that your database uses `UTF8` encoding (the default for new installations). Especially do not use `SQL_ASCII` encoding, as it can lead to unpredictable and unrecoverable errors. Enter `\l` to check your encoding. + Once complete, enter `\q` to exit the PostgreSQL shell. ## Verify Service Status diff --git a/docs/installation/2-redis.md b/docs/installation/2-redis.md index 2756a1ab0..c29deb5c2 100644 --- a/docs/installation/2-redis.md +++ b/docs/installation/2-redis.md @@ -4,18 +4,9 @@ [Redis](https://redis.io/) is an in-memory key-value store which NetBox employs for caching and queuing. This section entails the installation and configuration of a local Redis instance. If you already have a Redis service in place, skip to [the next section](3-netbox.md). -=== "Ubuntu" - - ```no-highlight - sudo apt install -y redis-server - ``` - -=== "CentOS" - - ```no-highlight - sudo yum install -y redis - sudo systemctl enable --now redis - ``` +```no-highlight +sudo apt install -y redis-server +``` Before continuing, verify that your installed version of Redis is at least v4.0: diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index 9a143319d..67a19e2e3 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -9,17 +9,11 @@ Begin by installing all system packages required by NetBox and its dependencies. !!! warning "Python 3.10 or later required" NetBox supports Python 3.10, 3.11, and 3.12. -=== "Ubuntu" - - ```no-highlight - sudo apt install -y python3 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev - ``` - -=== "CentOS" - - ```no-highlight - sudo yum install -y gcc libxml2-devel libxslt-devel libffi-devel libpq-devel openssl-devel redhat-rpm-config - ``` +```no-highlight +sudo apt install -y python3 python3-pip python3-venv python3-dev \ +build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev \ +libssl-dev zlib1g-dev +``` Before continuing, check that your installed Python version is at least 3.10: @@ -29,7 +23,7 @@ python3 -V ## Download NetBox -This documentation provides two options for installing NetBox: from a downloadable archive, or from the git repository. Installing from a package (option A below) requires manually fetching and extracting the archive for every future update, whereas installation via git (option B) allows for seamless upgrades by re-pulling the `master` branch. +This documentation provides two options for installing NetBox: from a downloadable archive, or from the git repository. Installing from a package (option A below) requires manually fetching and extracting the archive for every future update, whereas installation via git (option B) allows for seamless upgrades by checking out the latest release tag. ### Option A: Download a Release Archive @@ -55,28 +49,17 @@ cd /opt/netbox/ If `git` is not already installed, install it: -=== "Ubuntu" - - ```no-highlight - sudo apt install -y git - ``` - -=== "CentOS" - - ```no-highlight - sudo yum install -y git - ``` - -Next, clone the **master** branch of the NetBox GitHub repository into the current directory. (This branch always holds the current stable release.) - ```no-highlight -sudo git clone -b master --depth 1 https://github.com/netbox-community/netbox.git . +sudo apt install -y git ``` -!!! note - The `git clone` command above utilizes a "shallow clone" to retrieve only the most recent commit. If you need to download the entire history, omit the `--depth 1` argument. +Next, clone the git repository: -The `git clone` command should generate output similar to the following: +```no-highlight +sudo git clone https://github.com/netbox-community/netbox.git . +``` + +This command should generate output similar to the following: ``` Cloning into '.'... @@ -88,31 +71,24 @@ Receiving objects: 100% (996/996), 4.26 MiB | 9.81 MiB/s, done. Resolving deltas: 100% (148/148), done. ``` -!!! note - Installation via git also allows you to easily try out different versions of NetBox. To check out a [specific NetBox release](https://github.com/netbox-community/netbox/releases), use the `git checkout` command with the desired release tag. For example, `git checkout v3.0.8`. +Finally, check out the tag for the desired release. You can find these on our [releases page](https://github.com/netbox-community/netbox/releases). Replace `vX.Y.Z` with your selected release tag below. + +``` +sudo git checkout vX.Y.Z +``` + +Using this installation method enables easy upgrades in the future by simply checking out the latest release tag. ## Create the NetBox System User Create a system user account named `netbox`. We'll configure the WSGI and HTTP services to run under this account. We'll also assign this user ownership of the media directory. This ensures that NetBox will be able to save uploaded files. -=== "Ubuntu" - - ``` - sudo adduser --system --group netbox - sudo chown --recursive netbox /opt/netbox/netbox/media/ - sudo chown --recursive netbox /opt/netbox/netbox/reports/ - sudo chown --recursive netbox /opt/netbox/netbox/scripts/ - ``` - -=== "CentOS" - - ``` - sudo groupadd --system netbox - sudo adduser --system -g netbox netbox - sudo chown --recursive netbox /opt/netbox/netbox/media/ - sudo chown --recursive netbox /opt/netbox/netbox/reports/ - sudo chown --recursive netbox /opt/netbox/netbox/scripts/ - ``` +``` +sudo adduser --system --group netbox +sudo chown --recursive netbox /opt/netbox/netbox/media/ +sudo chown --recursive netbox /opt/netbox/netbox/reports/ +sudo chown --recursive netbox /opt/netbox/netbox/scripts/ +``` ## Configuration @@ -126,7 +102,7 @@ sudo cp configuration_example.py configuration.py Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following four are required for new installations: * `ALLOWED_HOSTS` -* `DATABASE` +* `DATABASES` (or `DATABASE`) * `REDIS` * `SECRET_KEY` @@ -144,18 +120,22 @@ If you are not yet sure what the domain name and/or IP address of the NetBox ins ALLOWED_HOSTS = ['*'] ``` -### DATABASE +### DATABASES -This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, update the `HOST` and `PORT` parameters accordingly. See the [configuration documentation](../configuration/required-parameters.md#database) for more detail on individual parameters. +This parameter holds the PostgreSQL database configuration details. The default database must be defined; additional databases may be defined as needed e.g. by plugins. + +A username and password must be defined for the default database. If the service is running on a remote host, update the `HOST` and `PORT` parameters accordingly. See the [configuration documentation](../configuration/required-parameters.md#databases) for more detail on individual parameters. ```python -DATABASE = { - 'NAME': 'netbox', # Database name - 'USER': 'netbox', # PostgreSQL username - 'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password - 'HOST': 'localhost', # Database server - 'PORT': '', # Database port (leave blank for default) - 'CONN_MAX_AGE': 300, # Max database connection age (seconds) +DATABASES = { + 'default': { + 'NAME': 'netbox', # Database name + 'USER': 'netbox', # PostgreSQL username + 'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password + 'HOST': 'localhost', # Database server + 'PORT': '', # Database port (leave blank for default) + 'CONN_MAX_AGE': 300, # Max database connection age (seconds) + } } ``` @@ -205,7 +185,7 @@ All Python packages required by NetBox are listed in `requirements.txt` and will ### Remote File Storage -By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](../configuration/system.md#storage_backend) in `configuration.py`. +By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](../configuration/system.md#storages) in `configuration.py`. ```no-highlight sudo sh -c "echo 'django-storages' >> /opt/netbox/local_requirements.txt" @@ -244,7 +224,7 @@ Once NetBox has been configured, we're ready to proceed with the actual installa * Create a Python virtual environment * Installs all required Python packages -* Run database schema migrations +* Run database schema migrations (skip with `--readonly`) * Builds the documentation locally (for offline use) * Aggregate static resource files on disk @@ -264,6 +244,9 @@ sudo PYTHON=/usr/bin/python3.10 /opt/netbox/upgrade.sh !!! note Upon completion, the upgrade script may warn that no existing virtual environment was detected. As this is a new installation, this warning can be safely ignored. +!!! note + To run the script on a node connected to a database in read-only mode, include the `--readonly` parameter. This will skip the application of any database migrations. + ## Create a Super User NetBox does not come with any predefined user accounts. You'll need to create a super user (administrative account) to be able to log into NetBox. First, enter the Python virtual environment created by the upgrade script: diff --git a/docs/installation/6-ldap.md b/docs/installation/6-ldap.md index 6ee1c9901..7de9f116d 100644 --- a/docs/installation/6-ldap.md +++ b/docs/installation/6-ldap.md @@ -6,18 +6,10 @@ This guide explains how to implement LDAP authentication using an external serve ### Install System Packages -On Ubuntu: - ```no-highlight sudo apt install -y libldap2-dev libsasl2-dev libssl-dev ``` -On CentOS: - -```no-highlight -sudo yum install -y openldap-devel python3-devel -``` - ### Install django-auth-ldap Activate the Python virtual environment and install the `django-auth-ldap` package using pip: diff --git a/docs/installation/index.md b/docs/installation/index.md index 76160068a..aefa39d17 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -1,11 +1,18 @@ # Installation -!!! info "NetBox Cloud" - The instructions below are for installing NetBox as a standalone, self-hosted application. For a Cloud-delivered solution, check out [NetBox Cloud](https://netboxlabs.com/netbox-cloud/) by NetBox Labs. +
-The installation instructions provided here have been tested to work on Ubuntu 22.04 and CentOS 8.3. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors. +- :material-clock-fast:{ .lg .middle } __Eager to Get Started?__ - + --- + + Check out the [NetBox Cloud Free Plan](https://netboxlabs.com/free-netbox-cloud/)! Skip the installation process and grab your own NetBox Cloud instance, preconfigured and ready to go in minutes. Completely free! + + [:octicons-arrow-right-24: Sign Up](https://signup.netboxlabs.com/) + +
+ +The installation instructions provided here have been tested to work on Ubuntu 22.04. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors. The following sections detail how to set up a new instance of NetBox: @@ -21,7 +28,7 @@ The following sections detail how to set up a new instance of NetBox: | Dependency | Supported Versions | |------------|--------------------| | Python | 3.10, 3.11, 3.12 | -| PostgreSQL | 12+ | +| PostgreSQL | 14+ | | Redis | 4.0+ | Below is a simplified overview of the NetBox application stack for reference: diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 6093b226e..f9a7a3189 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -20,15 +20,54 @@ NetBox requires the following dependencies: | Dependency | Supported Versions | |------------|--------------------| | Python | 3.10, 3.11, 3.12 | -| PostgreSQL | 12+ | +| PostgreSQL | 14+ | | Redis | 4.0+ | +### Version History + +| NetBox Version | Python min | Python max | PostgreSQL min | Redis min | Documentation | +|:--------------:|:----------:|:----------:|:--------------:|:---------:|:-------------------------------------------------------------------------------------------------:| +| 4.3 | 3.10 | 3.12 | 14 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.3.0/docs/installation/index.md) | +| 4.2 | 3.10 | 3.12 | 13 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md) | +| 4.1 | 3.10 | 3.12 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.1.0/docs/installation/index.md) | +| 4.0 | 3.10 | 3.12 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.0.0/docs/installation/index.md) | +| 3.7 | 3.8 | 3.11 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.7.0/docs/installation/index.md) | +| 3.6 | 3.8 | 3.11 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.6.0/docs/installation/index.md) | +| 3.5 | 3.8 | 3.10 | 11 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.5.0/docs/installation/index.md) | +| 3.4 | 3.8 | 3.10 | 11 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.4.0/docs/installation/index.md) | +| 3.3 | 3.8 | 3.10 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.3.0/docs/installation/index.md) | +| 3.2 | 3.8 | 3.10 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.2.0/docs/installation/index.md) | +| 3.1 | 3.7 | 3.9 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.1.0/docs/installation/index.md) | +| 3.0 | 3.7 | 3.9 | 9.6 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.0.0/docs/installation/index.md) | +| 2.11 | 3.6 | 3.9 | 9.6 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v2.11.0/docs/installation/index.md) | +| 2.10 | 3.6 | 3.8 | 9.6 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v2.10.0/docs/installation/index.md) | +| 2.9 | 3.6 | 3.8 | 9.5 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v2.9.0/docs/installation/index.md) | +| 2.8 | 3.6 | 3.8 | 9.5 | 3.4 | [Link](https://github.com/netbox-community/netbox/blob/v2.8.0/docs/installation/index.md) | +| 2.7 | 3.5 | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.7.0/docs/installation/index.md) | +| 2.6 | 3.5 | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.6.0/docs/installation/index.md) | +| 2.5 | 3.5 | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.5.0/docs/installation/index.md) | +| 2.4 | 3.4 | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.4.0/docs/installation/index.md) | +| 2.3 | 2.7 | 3.6 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.3.0/docs/installation/postgresql.md) | +| 2.2 | 2.7 | 3.6 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.2.0/docs/installation/postgresql.md) | +| 2.1 | 2.7 | 3.6 | 9.3 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.1.0/docs/installation/postgresql.md) | +| 2.0 | 2.7 | 3.6 | 9.3 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.0.0/docs/installation/postgresql.md) | +| 1.9 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.9.0-r1/docs/installation/postgresql.md) | +| 1.8 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.8.0/docs/installation/postgresql.md) | +| 1.7 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.7.0/docs/installation/postgresql.md) | +| 1.6 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.6.0/docs/installation/postgresql.md) | +| 1.5 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.5.0/docs/installation/postgresql.md) | +| 1.4 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.4.0/docs/installation/postgresql.md) | +| 1.3 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.3.0/docs/installation/postgresql.md) | +| 1.2 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.2.0/docs/installation/postgresql.md) | +| 1.1 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.1.0/docs/getting-started.md) | +| 1.0 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/1.0.0/docs/getting-started.md) | + ## 3. Install the Latest Release -As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by cloning the `master` branch of the git repository. +As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by checking out the latest production release from the git repository. !!! warning - Use the same method as you used to install NetBox originally + Use the same method as you used to install NetBox originally. If you are not sure how NetBox was installed originally, check with this command: @@ -36,10 +75,7 @@ If you are not sure how NetBox was installed originally, check with this command ls -ld /opt/netbox /opt/netbox/.git ``` -If NetBox was installed from a release package, then `/opt/netbox` will be a -symlink pointing to the current version, and `/opt/netbox/.git` will not -exist. If it was installed from git, then `/opt/netbox` and -`/opt/netbox/.git` will both exist as normal directories. +If NetBox was installed from a release package, then `/opt/netbox` will be a symlink pointing to the current version, and `/opt/netbox/.git` will not exist. If it was installed from git, then `/opt/netbox` and `/opt/netbox/.git` will both exist as normal directories. ### Option A: Download a Release @@ -84,20 +120,22 @@ If you followed the original installation guide to set up gunicorn, be sure to c sudo cp /opt/netbox-$OLDVER/gunicorn.py /opt/netbox/ ``` -### Option B: Clone the Git Repository +### Option B: Check Out a Git Release -This guide assumes that NetBox is installed at `/opt/netbox`. Pull down the most recent iteration of the master branch: +This guide assumes that NetBox is installed at `/opt/netbox`. First, determine the latest release either by visiting our [releases page](https://github.com/netbox-community/netbox/releases) or by running the following command: -```no-highlight -cd /opt/netbox -sudo git checkout master -sudo git pull origin master +``` +git ls-remote --tags https://github.com/netbox-community/netbox.git \ + | grep -o 'refs/tags/v[0-9]*\.[0-9]*\.[0-9]*$' \ + | tail -n 1 \ + | sed 's|refs/tags/||' ``` -!!! info "Checking out an older release" - If you need to upgrade to an older version rather than the current stable release, you can check out any valid [git tag](https://github.com/netbox-community/netbox/tags), each of which represents a release. For example, to checkout the code for NetBox v2.11.11, do: +Check out the desired release by specifying its tag. For example: - sudo git checkout v2.11.11 +``` +sudo git checkout v4.2.7 +``` ## 4. Run the Upgrade Script @@ -114,6 +152,9 @@ sudo ./upgrade.sh sudo PYTHON=/usr/bin/python3.10 ./upgrade.sh ``` +!!! note + To run the script on a node connected to a database in read-only mode, include the `--readonly` parameter. This will skip the application of any database migrations. + This script performs the following actions: * Destroys and rebuilds the Python virtual environment diff --git a/docs/integrations/graphql-api.md b/docs/integrations/graphql-api.md index 425c3adda..39309671c 100644 --- a/docs/integrations/graphql-api.md +++ b/docs/integrations/graphql-api.md @@ -1,6 +1,6 @@ # GraphQL API Overview -NetBox provides a read-only [GraphQL](https://graphql.org/) API to complement its REST API. This API is powered by [Strawberry Django](https://strawberry-graphql.github.io/strawberry-django/). +NetBox provides a read-only [GraphQL](https://graphql.org/) API to complement its REST API. This API is powered by [Strawberry Django](https://strawberry.rocks/). ## Queries @@ -11,7 +11,7 @@ curl -H "Authorization: Token $TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ http://netbox/graphql/ \ ---data '{"query": "query {circuit_list(status:\"active\") {cid provider {name}}}"}' +--data '{"query": "query {circuit_list(filters:{status: STATUS_ACTIVE}) {cid provider {name}}}"}' ``` The response will include the requested data formatted as JSON: @@ -47,23 +47,52 @@ NetBox provides both a singular and plural query field for each object type: For example, query `device(id:123)` to fetch a specific device (identified by its unique ID), and query `device_list` (with an optional set of filters) to fetch all devices. -For more detail on constructing GraphQL queries, see the [GraphQL queries documentation](https://graphql.org/learn/queries/). For filtering and lookup syntax, please refer to the [Strawberry Django documentation](https://strawberry-graphql.github.io/strawberry-django/guide/filters/). +For more detail on constructing GraphQL queries, see the [GraphQL queries documentation](https://graphql.org/learn/queries/). For filtering and lookup syntax, please refer to the [Strawberry Django documentation](https://strawberry.rocks/docs/django/guide/filters). ## Filtering -The GraphQL API employs the same filtering logic as the UI and REST API. Filters can be specified as key-value pairs within parentheses immediately following the query name. For example, the following will return only sites within the North Carolina region with a status of active: +!!! note "Changed in NetBox v4.3" + The filtering syntax fo the GraphQL API has changed substantially in NetBox v4.3. + +Filters can be specified as key-value pairs within parentheses immediately following the query name. For example, the following will return only active sites: ``` query { - site_list(filters: {region: "us-nc", status: "active"}) { + site_list( + filters: { + status: STATUS_ACTIVE + } + ) { name } } ``` -In addition, filtering can be done on list of related objects as shown in the following query: + +Filters can be combined with logical operators, such as `OR` and `NOT`. For example, the following will return every site that is planned _or_ assigned to a tenant named Foo: ``` -{ +query { + site_list( + filters: { + status: STATUS_PLANNED, + OR: { + tenant: { + name: { + exact: "Foo" + } + } + } + } + ) { + name + } +} +``` + +Filtering can also be applied to related objects. For example, the following query will return only enabled interfaces for each device: + +``` +query { device_list { id name @@ -98,9 +127,21 @@ Certain queries can return multiple types of objects, for example cable terminat } } } +``` + +The field "class_type" is an easy way to distinguish what type of object it is when viewing the returned data, or when filtering. It contains the class name, for example "CircuitTermination" or "ConsoleServerPort". + +## Pagination + +Queries can be paginated by specifying pagination in the query and supplying an offset and optionaly a limit in the query. If no limit is given, a default of 100 is used. Queries are not paginated unless requested in the query. An example paginated query is shown below: ``` -The field "class_type" is an easy way to distinguish what type of object it is when viewing the returned data, or when filtering. It contains the class name, for example "CircuitTermination" or "ConsoleServerPort". +query { + device_list(pagination: { offset: 0, limit: 20 }) { + id + } +} +``` ## Authentication diff --git a/docs/integrations/rest-api.md b/docs/integrations/rest-api.md index 215b561a7..7a0d3e176 100644 --- a/docs/integrations/rest-api.md +++ b/docs/integrations/rest-api.md @@ -2,7 +2,7 @@ ## What is a REST API? -REST stands for [representational state transfer](https://en.wikipedia.org/wiki/Representational_state_transfer). It's a particular type of API which employs HTTP requests and [JavaScript Object Notation (JSON)](https://www.json.org/) to facilitate create, retrieve, update, and delete (CRUD) operations on objects within an application. Each type of operation is associated with a particular HTTP verb: +REST stands for [representational state transfer](https://en.wikipedia.org/wiki/REST). It's a particular type of API which employs HTTP requests and [JavaScript Object Notation (JSON)](https://www.json.org/) to facilitate create, retrieve, update, and delete (CRUD) operations on objects within an application. Each type of operation is associated with a particular HTTP verb: * `GET`: Retrieve an object or list of objects * `POST`: Create an object @@ -217,26 +217,34 @@ If we wanted to assign this IP address to a virtual machine interface instead, w ### Brief Format -Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of available objects without any related data, such as when populating a drop-down list in a form. As an example, the default (complete) format of an IP address looks like this: +Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of available objects without any related data, such as when populating a drop-down list in a form. As an example, the default (complete) format of a prefix looks like this: -``` +```no-highlight GET /api/ipam/prefixes/13980/ +``` +```json { "id": 13980, "url": "http://netbox/api/ipam/prefixes/13980/", + "display_url": "http://netbox/api/ipam/prefixes/13980/", + "display": "192.0.2.0/24", "family": { "value": 4, "label": "IPv4" }, "prefix": "192.0.2.0/24", - "site": { - "id": 3, - "url": "http://netbox/api/dcim/sites/17/", - "name": "Site 23A", - "slug": "site-23a" - }, "vrf": null, + "scope_type": "dcim.site", + "scope_id": 3, + "scope": { + "id": 3, + "url": "http://netbox/api/dcim/sites/3/", + "display": "Site 23A", + "name": "Site 23A", + "slug": "site-23a", + "description": "" + }, "tenant": null, "vlan": null, "status": { @@ -250,24 +258,36 @@ GET /api/ipam/prefixes/13980/ "slug": "staging" }, "is_pool": false, + "mark_utilized": false, "description": "Example prefix", + "comments": "", "tags": [], "custom_fields": {}, - "created": "2018-12-10", - "last_updated": "2019-03-01T20:02:46.173540Z" + "created": "2025-03-01T20:01:23.458302Z", + "last_updated": "2025-03-01T20:02:46.173540Z", + "children": 0, + "_depth": 0 } ``` The brief format is much more terse: -``` +```no-highlight GET /api/ipam/prefixes/13980/?brief=1 +``` +```json { "id": 13980, "url": "http://netbox/api/ipam/prefixes/13980/", - "family": 4, - "prefix": "10.40.3.0/24" + "display": "192.0.2.0/24", + "family": { + "value": 4, + "label": "IPv4" + }, + "prefix": "192.0.2.0/24", + "description": "Example prefix", + "_depth": 0 } ``` @@ -400,25 +420,31 @@ curl -s -X POST \ -H "Authorization: Token $TOKEN" \ -H "Content-Type: application/json" \ http://netbox/api/ipam/prefixes/ \ ---data '{"prefix": "192.0.2.0/24", "site": 6}' | jq '.' +--data '{"prefix": "192.0.2.0/24", "scope_type": "dcim.site", "scope_id": 6}' | jq '.' ``` ```json { "id": 18691, "url": "http://netbox/api/ipam/prefixes/18691/", + "display_url": "http://netbox/api/ipam/prefixes/18691/", + "display": "192.0.2.0/24", "family": { "value": 4, "label": "IPv4" }, "prefix": "192.0.2.0/24", - "site": { + "vrf": null, + "scope_type": "dcim.site", + "scope_id": 6, + "scope": { "id": 6, "url": "http://netbox/api/dcim/sites/6/", + "display": "US-East 4", "name": "US-East 4", - "slug": "us-east-4" + "slug": "us-east-4", + "description": "" }, - "vrf": null, "tenant": null, "vlan": null, "status": { @@ -427,11 +453,15 @@ http://netbox/api/ipam/prefixes/ \ }, "role": null, "is_pool": false, + "mark_utilized": false, "description": "", + "comments": "", "tags": [], "custom_fields": {}, - "created": "2020-08-04", - "last_updated": "2020-08-04T20:08:39.007125Z" + "created": "2025-04-29T15:44:47.597092Z", + "last_updated": "2025-04-29T15:44:47.597092Z", + "children": 0, + "_depth": 0 } ``` @@ -490,18 +520,24 @@ http://netbox/api/ipam/prefixes/18691/ \ { "id": 18691, "url": "http://netbox/api/ipam/prefixes/18691/", + "display_url": "http://netbox/api/ipam/prefixes/18691/", + "display": "192.0.2.0/24", "family": { "value": 4, "label": "IPv4" }, "prefix": "192.0.2.0/24", - "site": { + "vrf": null, + "scope_type": "dcim.site", + "scope_id": 6, + "scope": { "id": 6, "url": "http://netbox/api/dcim/sites/6/", + "display": "US-East 4", "name": "US-East 4", - "slug": "us-east-4" + "slug": "us-east-4", + "description": "" }, - "vrf": null, "tenant": null, "vlan": null, "status": { @@ -510,11 +546,15 @@ http://netbox/api/ipam/prefixes/18691/ \ }, "role": null, "is_pool": false, + "mark_utilized": false, "description": "", + "comments": "", "tags": [], "custom_fields": {}, - "created": "2020-08-04", - "last_updated": "2020-08-04T20:14:55.709430Z" + "created": "2025-04-29T15:44:47.597092Z", + "last_updated": "2025-04-29T15:49:40.689109Z", + "children": 0, + "_depth": 0 } ``` @@ -568,6 +608,23 @@ http://netbox/api/dcim/sites/ \ !!! note The bulk deletion of objects is an all-or-none operation, meaning that if NetBox fails to delete any of the specified objects (e.g. due a dependency by a related object), the entire operation will be aborted and none of the objects will be deleted. +## Uploading Files + +As JSON does not support the inclusion of binary data, files cannot be uploaded using JSON-formatted API requests. Instead, we can use form data encoding to attach a local file. + +For example, we can upload an image attachment using the `curl` command shown below. Note that the `@` signifies a local file on disk to be uploaded. + +```no-highlight +curl -X POST \ +-H "Authorization: Token $TOKEN" \ +-H "Accept: application/json; indent=4" \ +-F "object_type=dcim.site" \ +-F "object_id=2" \ +-F "name=attachment1.png" \ +-F "image=@local_file.png" \ +http://netbox/api/extras/image-attachments/ +``` + ## Authentication The NetBox REST API primarily employs token-based authentication. For convenience, cookie-based authentication can also be used when navigating the browsable API. @@ -653,6 +710,7 @@ Note that we are _not_ passing an existing REST API token with this request. If { "id": 6, "url": "https://netbox/api/users/tokens/6/", + "display_url": "https://netbox/api/users/tokens/6/", "display": "**********************************3c9cb9", "user": { "id": 2, diff --git a/docs/introduction.md b/docs/introduction.md index b8442dad7..c8e5ee8ac 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -79,5 +79,5 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and | HTTP service | nginx or Apache | | WSGI service | gunicorn or uWSGI | | Application | Django/Python | -| Database | PostgreSQL 12+ | +| Database | PostgreSQL 14+ | | Task queuing | Redis/django-rq | diff --git a/docs/models/core/datasource.md b/docs/models/core/datasource.md index 0e18a2aae..527d93939 100644 --- a/docs/models/core/datasource.md +++ b/docs/models/core/datasource.md @@ -44,6 +44,12 @@ A set of rules (one per line) identifying filenames to ignore during synchroniza | `*.txt` | Ignore any files with a `.txt` extension | | `data???.json` | Ignore e.g. `data123.json` | +### Sync Interval + +!!! info "This field was introduced in NetBox v4.3." + +The interval at which the data source should automatically synchronize. If not set, the data source must be synchronized manually. + ### Last Synced The date and time at which the source was most recently synchronized successfully. diff --git a/docs/models/dcim/devicerole.md b/docs/models/dcim/devicerole.md index 786170f2b..abff149d6 100644 --- a/docs/models/dcim/devicerole.md +++ b/docs/models/dcim/devicerole.md @@ -4,6 +4,12 @@ Devices can be organized by functional roles, which are fully customizable by th ## Fields +### Parent + +!!! info "This field was introduced in NetBox v4.3." + +The parent role of which this role is a child (optional). + ### Name A unique human-friendly name. diff --git a/docs/models/dcim/inventoryitem.md b/docs/models/dcim/inventoryitem.md index 2d648341b..6aed0fc86 100644 --- a/docs/models/dcim/inventoryitem.md +++ b/docs/models/dcim/inventoryitem.md @@ -1,5 +1,8 @@ # Inventory Items +!!! warning "Deprecation Warning" + Beginning in NetBox v4.3, the use of inventory items has been deprecated. They are planned for removal in a future NetBox release. Users are strongly encouraged to begin using [modules](./module.md) and [module types](./moduletype.md) in place of inventory items. Modules provide enhanced functionality and can be configured with user-defined attributes. + Inventory items represent hardware components installed within a device, such as a power supply or CPU or line card. They are intended to be used primarily for inventory purposes. Inventory items are hierarchical in nature, such that any individual item may be designated as the parent for other items. For example, an inventory item might be created to represent a line card which houses several SFP optics, each of which exists as a child item within the device. An inventory item may also be associated with a specific component within the same device. For example, you may wish to associate a transceiver with an interface. diff --git a/docs/models/dcim/inventoryitemrole.md b/docs/models/dcim/inventoryitemrole.md index 50eb61abd..b77637604 100644 --- a/docs/models/dcim/inventoryitemrole.md +++ b/docs/models/dcim/inventoryitemrole.md @@ -1,5 +1,8 @@ # Inventory Item Roles +!!! warning "Deprecation Warning" + Beginning in NetBox v4.3, the use of inventory items has been deprecated. They are planned for removal in a future NetBox release. Users are strongly encouraged to begin using [modules](./module.md) and [module types](./moduletype.md) in place of inventory items. Modules provide enhanced functionality and can be configured with user-defined attributes. + Inventory items can be organized by functional roles, which are fully customizable by the user. For example, you might create roles for power supplies, fans, interface optics, etc. ## Fields diff --git a/docs/models/dcim/inventoryitemtemplate.md b/docs/models/dcim/inventoryitemtemplate.md index 02fde5995..7d8ff504d 100644 --- a/docs/models/dcim/inventoryitemtemplate.md +++ b/docs/models/dcim/inventoryitemtemplate.md @@ -1,3 +1,6 @@ # Inventory Item Templates +!!! warning "Deprecation Warning" + Beginning in NetBox v4.3, the use of inventory items has been deprecated. They are planned for removal in a future NetBox release. Users are strongly encouraged to begin using [modules](./module.md) and [module types](./moduletype.md) in place of inventory items. Modules provide enhanced functionality and can be configured with user-defined attributes. + A template for an inventory item that will be automatically created when instantiating a new device. All attributes of this object will be copied to the new inventory item, including the associations with a parent item and assigned component, if any. See the [inventory item](./inventoryitem.md) documentation for more detail. diff --git a/docs/models/dcim/moduletype.md b/docs/models/dcim/moduletype.md index 7077e16c2..88f04466a 100644 --- a/docs/models/dcim/moduletype.md +++ b/docs/models/dcim/moduletype.md @@ -43,3 +43,11 @@ The numeric weight of the module, including a unit designation (e.g. 3 kilograms ### Airflow The direction in which air circulates through the device chassis for cooling. + +### Profile + +The assigned [profile](./moduletypeprofile.md) for the type of module. Profiles can be used to classify module types by function (e.g. power supply, hard disk, etc.), and they support the addition of user-configurable attributes on module types. The assignment of a module type to a profile is optional. + +### Attributes + +Depending on the module type's assigned [profile](./moduletypeprofile.md) (if any), one or more user-defined attributes may be available to configure. diff --git a/docs/models/dcim/moduletypeprofile.md b/docs/models/dcim/moduletypeprofile.md new file mode 100644 index 000000000..80345c82b --- /dev/null +++ b/docs/models/dcim/moduletypeprofile.md @@ -0,0 +1,40 @@ +# Module Type Profiles + +!!! info "This model was introduced in NetBox v4.3." + +Each [module type](./moduletype.md) may optionally be assigned a profile according to its classification. A profile can extend module types with user-configured attributes. For example, you might want to specify the input current and voltage of a power supply, or the clock speed and number of cores for a processor. + +Module type attributes are managed via the configuration of a [JSON schema](https://json-schema.org/) on the profile. For example, the following schema introduces three module type attributes, two of which are designated as required attributes. + +```json +{ + "properties": { + "type": { + "type": "string", + "title": "Disk type", + "enum": ["HD", "SSD", "NVME"], + "default": "HD" + }, + "capacity": { + "type": "integer", + "title": "Capacity (GB)", + "description": "Gross disk size" + }, + "speed": { + "type": "integer", + "title": "Speed (RPM)" + } + }, + "required": [ + "type", "capacity" + ] +} +``` + +The assignment of module types to a profile is optional. The designation of a schema for a profile is also optional: A profile can be used simply as a mechanism for classifying module types if the addition of custom attributes is not needed. + +## Fields + +### Schema + +This field holds the [JSON schema](https://json-schema.org/) for the profile. The configured JSON schema must be valid (or the field must be null). diff --git a/docs/models/dcim/poweroutlet.md b/docs/models/dcim/poweroutlet.md index a99f60b23..22a7ec63e 100644 --- a/docs/models/dcim/poweroutlet.md +++ b/docs/models/dcim/poweroutlet.md @@ -29,6 +29,19 @@ An alternative physical label identifying the power outlet. The type of power outlet. +### Status + +The operational status of the power outlet. By default, the following statuses are available: + +* Enabled +* Disabled +* Faulty + +!!! tip "Custom power outlet statuses" + Additional power outlet statuses may be defined by setting `PowerOutlet.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. + +!!! info "This field was introduced in NetBox v4.3." + ### Color !!! info "This field was introduced in NetBox v4.2." diff --git a/docs/models/dcim/racktype.md b/docs/models/dcim/racktype.md index b5f2d99e7..5298e8b26 100644 --- a/docs/models/dcim/racktype.md +++ b/docs/models/dcim/racktype.md @@ -40,7 +40,9 @@ The number of the numerically lowest unit in the rack. This value defaults to on ### Outer Dimensions -The external width and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches. +The external width, height and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches. + +!!! info "The `outer_height` field was introduced in NetBox v4.3." ### Mounting Depth diff --git a/docs/models/extras/branch.md b/docs/models/extras/branch.md deleted file mode 100644 index 4599fed85..000000000 --- a/docs/models/extras/branch.md +++ /dev/null @@ -1,16 +0,0 @@ -# Branches - -!!! danger "Deprecated Feature" - This feature has been deprecated in NetBox v4.2 and will be removed in a future release. Please consider using the [netbox-branching plugin](https://github.com/netboxlabs/netbox-branching), which provides much more robust functionality. - -A branch is a collection of related [staged changes](./stagedchange.md) that have been prepared for merging into the active database. A branch can be merged by executing its `commit()` method. Deleting a branch will delete all its related changes. - -## Fields - -### Name - -The branch's name. - -### User - -The user to which the branch belongs (optional). diff --git a/docs/models/extras/configtemplate.md b/docs/models/extras/configtemplate.md index b580d6885..6b245e5e9 100644 --- a/docs/models/extras/configtemplate.md +++ b/docs/models/extras/configtemplate.md @@ -12,10 +12,6 @@ See the [configuration rendering documentation](../../features/configuration-ren A unique human-friendly name. -### Weight - -A numeric value which influences the order in which context data is merged. Contexts with a lower weight are merged before those with a higher weight. - ### Data File Template code may optionally be sourced from a remote [data file](../core/datafile.md), which is synchronized from a remote data source. When designating a data file, there is no need to specify template code: It will be populated automatically from the data file. @@ -27,3 +23,27 @@ Jinja2 template code, if being defined locally rather than replicated from a dat ### Environment Parameters A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior. + +### MIME Type + +!!! info "This field was introduced in NetBox v4.3." + +The MIME type to indicate in the response when rendering the configuration template (optional). Defaults to `text/plain`. + +### File Name + +!!! info "This field was introduced in NetBox v4.3." + +The file name to give to the rendered export file (optional). + +### File Extension + +!!! info "This field was introduced in NetBox v4.3." + +The file extension to append to the file name in the response (optional). + +### As Attachment + +!!! info "This field was introduced in NetBox v4.3." + +If selected, the rendered content will be returned as a file attachment, rather than displayed directly in-browser (where supported). \ No newline at end of file diff --git a/docs/models/extras/exporttemplate.md b/docs/models/extras/exporttemplate.md index d2f9292c6..86e1ae04a 100644 --- a/docs/models/extras/exporttemplate.md +++ b/docs/models/extras/exporttemplate.md @@ -20,10 +20,20 @@ Template code may optionally be sourced from a remote [data file](../core/datafi Jinja2 template code for rendering the exported data. +### Environment Parameters + +!!! info "This field was introduced in NetBox v4.3." + +A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior. + ### MIME Type The MIME type to indicate in the response when rendering the export template (optional). Defaults to `text/plain`. +### File Name + +The file name to give to the rendered export file (optional). + ### File Extension The file extension to append to the file name in the response (optional). diff --git a/docs/models/extras/stagedchange.md b/docs/models/extras/stagedchange.md deleted file mode 100644 index 0693a32d3..000000000 --- a/docs/models/extras/stagedchange.md +++ /dev/null @@ -1,29 +0,0 @@ -# Staged Changes - -!!! danger "Deprecated Feature" - This feature has been deprecated in NetBox v4.2 and will be removed in a future release. Please consider using the [netbox-branching plugin](https://github.com/netboxlabs/netbox-branching), which provides much more robust functionality. - -A staged change represents the creation of a new object or the modification or deletion of an existing object to be performed at some future point. Each change must be assigned to a [branch](./branch.md). - -Changes can be applied individually via the `apply()` method, however it is recommended to apply changes in bulk using the parent branch's `commit()` method. - -## Fields - -!!! warning - Staged changes are not typically created or manipulated directly, but rather effected through the use of the [`checkout()`](../../plugins/development/staged-changes.md) context manager. - -### Branch - -The [branch](./branch.md) to which this change belongs. - -### Action - -The type of action this change represents: `create`, `update`, or `delete`. - -### Object - -A generic foreign key referencing the existing object to which this change applies. - -### Data - -JSON representation of the changes being made to the object (not applicable for deletions). diff --git a/docs/models/extras/tableconfig.md b/docs/models/extras/tableconfig.md new file mode 100644 index 000000000..e5484ec64 --- /dev/null +++ b/docs/models/extras/tableconfig.md @@ -0,0 +1,43 @@ +# Table Configs + +This object represents the saved configuration of an object table in NetBox. Table configs can be crafted, saved, and shared among users to apply specific views within object lists. Each table config can specify which table columns to display, the order in which to display them, and which columns are used for sorting. + +For example, you might wish to create a table config for the devices list to assist in inventory tasks. This view might show the device name, location, serial number, and asset tag, but omit operational details like IP addresses. Once applied, this table config can be saved for reuse in future audits. + +## Fields + +### Name + +A human-friendly name for the table config. + +### User + +The user to which this filter belongs. The current user will be assigned automatically when saving a table config via the UI, and cannot be changed. + +### Object Type + +The type of NetBox object to which the table config pertains. + +### Table + +The name of the specific table to which the table config pertains. (Some NetBox object use multiple tables.) + +### Weight + +A numeric weight used to influence the order in which table configs are listed. Table configs with a lower weight will be listed before those with a higher weight. Table configs having the same weight will be ordered alphabetically. + +### Enabled + +Determines whether this table config can be used. Disabled table configs will not appear as options in the UI, however they will be included in API results. + +### Shared + +Determines whether this table config is intended for use by all users or only its owner. Note that deselecting this option does **not** hide the table config from other users; it is merely excluded from the list of available table configs in UI object list views. + +### Ordering + +A list of column names by which the table is to be ordered. If left blank, the table's default ordering will be used. + +### Columns + +A list of columns to be displayed in the table. The table will render these columns in the order they appear in the list. At least one column must be selected. diff --git a/docs/models/extras/tag.md b/docs/models/extras/tag.md index 39de48261..c4bc91b5a 100644 --- a/docs/models/extras/tag.md +++ b/docs/models/extras/tag.md @@ -16,6 +16,12 @@ A unique URL-friendly identifier. (This value will be used for filtering.) This The color to use when displaying the tag in the NetBox UI. +### Weight + +A numeric weight employed to influence the ordering of tags. Tags with a lower weight will be listed before those with higher weights. Values must be within the range **0** to **32767**. + +!!! info "This field was introduced in NetBox v4.3." + ### Object Types The assignment of a tag may be limited to a prescribed set of objects. For example, it may be desirable to limit the application of a specific tag to only devices and virtual machines. diff --git a/docs/models/ipam/iprange.md b/docs/models/ipam/iprange.md index 71f0884d9..fd439998a 100644 --- a/docs/models/ipam/iprange.md +++ b/docs/models/ipam/iprange.md @@ -2,6 +2,12 @@ This model represents an arbitrary range of individual IPv4 or IPv6 addresses, inclusive of its starting and ending addresses. For instance, the range 192.0.2.10 to 192.0.2.20 has eleven members. (The total member count is available as the `size` property on an IPRange instance.) Like [prefixes](./prefix.md) and [IP addresses](./ipaddress.md), each IP range may optionally be assigned to a [VRF](./vrf.md). +Each IP range can be marked as populated, which instructs NetBox to treat the range as though every IP address within it has been created (even though these individual IP addresses don't actually exist in the database). This can be helpful in scenarios where the management of a subset of IP addresses has been deferred to an external system of record, such as a DHCP server. NetBox will prohibit the creation of individual IP addresses within a range that has been marked as populated. + +An IP range can also be marked as utilized. This will cause its utilization to always be reported as 100% when viewing the range or when calculating the utilization of a parent prefix. (If not enabled, a range's utilization is calculated based on the number of IP addresses which have been created within it.) + +Typically, IP ranges marked as populated should also be marked as utilized, although there may be scenarios where this is undesirable (e.g. when reclaiming old IP space). An IP range which has been marked as populated but _not_ marked as utilized will always report a utilization of 0%, as it cannot contain child IP addresses. + ## Fields ### VRF @@ -29,6 +35,12 @@ The IP range's operational status. Note that the status of a range does _not_ ha !!! tip Additional statuses may be defined by setting `IPRange.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. +### Mark Populated + +!!! note "This field was added in NetBox v4.3." + +If enabled, NetBox will treat this IP range as being fully populated when calculating available IP space. It will also prevent the creation of IP addresses which fall within the declared range (and assigned VRF, if any). + ### Mark Utilized If enabled, the IP range will be considered 100% utilized regardless of how many IP addresses are defined within it. This is useful for documenting DHCP ranges, for example. diff --git a/docs/models/ipam/service.md b/docs/models/ipam/service.md index 316828b61..0d5f12a17 100644 --- a/docs/models/ipam/service.md +++ b/docs/models/ipam/service.md @@ -6,6 +6,15 @@ To aid in the efficient creation of services, users may opt to first create a [s ## Fields +### Parent + +The parent object to which the service is assigned. This must be one of [Device](../dcim/device.md), +[VirtualMachine](../virtualization/virtualmachine.md), or [FHRP Group](./fhrpgroup.md). + +!!! note "Changed in NetBox v4.3" + + Previously, `parent` was a property that pointed to either a Device or Virtual Machine. With the capability to assign services to FHRP groups, this is a unified in a concrete field. + ### Name A service or protocol name. diff --git a/docs/models/tenancy/contact.md b/docs/models/tenancy/contact.md index eac630180..f277ab499 100644 --- a/docs/models/tenancy/contact.md +++ b/docs/models/tenancy/contact.md @@ -4,9 +4,11 @@ A contact represents an individual or group that has been associated with an obj ## Fields -### Group +### Groups -The [contact group](./contactgroup.md) to which this contact is assigned (if any). +The [contact groups](./contactgroup.md) to which this contact is assigned (if any). + +!!! info "This field was renamed from `group` to `groups` in NetBox v4.3, and now supports the assignment of a contact to more than one group." ### Name diff --git a/docs/models/vpn/l2vpn.md b/docs/models/vpn/l2vpn.md index 1167c1c17..983095ef8 100644 --- a/docs/models/vpn/l2vpn.md +++ b/docs/models/vpn/l2vpn.md @@ -33,6 +33,19 @@ The technology employed in forming and operating the L2VPN. Choices include: !!! note Designating the type as VPWS, EPL, EP-LAN, EP-TREE will limit the L2VPN instance to two terminations. +### Status + +The operational status of the L2VPN. By default, the following statuses are available: + +* Active (default) +* Planned +* Faulty + +!!! tip "Custom L2VPN statuses" + Additional L2VPN statuses may be defined by setting `L2VPN.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. + +!!! info "This field was introduced in NetBox v4.3." + ### Identifier An optional numeric identifier. This can be used to track a pseudowire ID, for example. diff --git a/docs/plugins/development/filtersets.md b/docs/plugins/development/filtersets.md index d803ce2f4..e19b3a733 100644 --- a/docs/plugins/development/filtersets.md +++ b/docs/plugins/development/filtersets.md @@ -1,6 +1,6 @@ # Filters & Filter Sets -Filter sets define the mechanisms available for filtering or searching through a set of objects in NetBox. For instance, sites can be filtered by their parent region or group, status, facility ID, and so on. The same filter set is used consistently for a model whether the request is made via the UI, REST API, or GraphQL API. NetBox employs the [django-filters2](https://django-tables2.readthedocs.io/en/latest/) library to define filter sets. +Filter sets define the mechanisms available for filtering or searching through a set of objects in NetBox. For instance, sites can be filtered by their parent region or group, status, facility ID, and so on. The same filter set is used consistently for a model whether the request is made via the UI or REST API. (Note that the GraphQL API uses a separate filter class.) NetBox employs the [django-filters2](https://django-tables2.readthedocs.io/en/latest/) library to define filter sets. ## FilterSet Classes @@ -61,6 +61,11 @@ class MyModelViewSet(...): The `TagFilter` class is available for all models which support tag assignment (those which inherit from `NetBoxModel` or `TagsMixin`). This filter subclasses django-filter's `ModelMultipleChoiceFilter` to work with NetBox's `TaggedItem` class. +This class filters `tags` using the `slug` field. For example: + +`GET /api/dcim/sites/?tag=alpha&tag=bravo` + + ```python from django_filters import FilterSet from extras.filters import TagFilter @@ -68,3 +73,19 @@ from extras.filters import TagFilter class MyModelFilterSet(FilterSet): tag = TagFilter() ``` + +### TagIDFilter + +The `TagIDFilter` class is available for all models which support tag assignment (those which inherit from `NetBoxModel` or `TagsMixin`). This filter subclasses django-filter's `ModelMultipleChoiceFilter` to work with NetBox's `TaggedItem` class. + +This class filters `tags` using the `id` field. For example: + +`GET /api/dcim/sites/?tag_id=100&tag_id=200` + +```python +from django_filters import FilterSet +from extras.filters import TagIDFilter + +class MyModelFilterSet(FilterSet): + tag_id = TagIDFilter() +``` diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md index 246816349..56bde5e41 100644 --- a/docs/plugins/development/index.md +++ b/docs/plugins/development/index.md @@ -103,6 +103,7 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i | `name` | Raw plugin name; same as the plugin's source directory | | `verbose_name` | Human-friendly name for the plugin | | `version` | Current release ([semantic versioning](https://semver.org/) is encouraged) | +| `release_track` | An alternate release track (e.g. `dev` or `beta`) to which a release belongs | | `description` | Brief description of the plugin's purpose | | `author` | Name of plugin's author | | `author_email` | Author's public email address | @@ -204,6 +205,7 @@ To ease development, it is recommended to go ahead and install the plugin at thi ```no-highlight $ pip install -e . ``` + More information on editable builds can be found at [Editable installs for pyproject.toml ](https://peps.python.org/pep-0660/). ## Configure NetBox diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md index 03cedda16..508c4ce89 100644 --- a/docs/plugins/development/models.md +++ b/docs/plugins/development/models.md @@ -117,6 +117,10 @@ For more information about database migrations, see the [Django documentation](h ::: netbox.models.features.CloningMixin +::: netbox.models.features.ContactsMixin + +!!! info "Plugin support for ContactsMixin was introduced in NetBox v4.3." + ::: netbox.models.features.CustomLinksMixin ::: netbox.models.features.CustomFieldsMixin @@ -125,9 +129,6 @@ For more information about database migrations, see the [Django documentation](h ::: netbox.models.features.EventRulesMixin -!!! note - `EventRulesMixin` was renamed from `WebhooksMixin` in NetBox v3.7. - ::: netbox.models.features.ExportTemplatesMixin ::: netbox.models.features.JobsMixin diff --git a/docs/plugins/development/navigation.md b/docs/plugins/development/navigation.md index 90b523473..b5e2694b4 100644 --- a/docs/plugins/development/navigation.md +++ b/docs/plugins/development/navigation.md @@ -64,13 +64,14 @@ item1 = PluginMenuItem( A `PluginMenuItem` has the following attributes: -| Attribute | Required | Description | -|---------------|----------|----------------------------------------------------------------------------------------------------------| -| `link` | Yes | Name of the URL path to which this menu item links | -| `link_text` | Yes | The text presented to the user | -| `permissions` | - | A list of permissions required to display this link | -| `staff_only` | - | Display only for users who have `is_staff` set to true (any specified permissions will also be required) | -| `buttons` | - | An iterable of PluginMenuButton instances to include | +| Attribute | Required | Description | +|-----------------|----------|----------------------------------------------------------------------------------------------------------| +| `link` | Yes | Name of the URL path to which this menu item links | +| `link_text` | Yes | The text presented to the user | +| `permissions` | - | A list of permissions required to display this link | +| `auth_required` | - | Display only for authenticated users | +| `staff_only` | - | Display only for users who have `is_staff` set to true (any specified permissions will also be required) | +| `buttons` | - | An iterable of PluginMenuButton instances to include | ## Menu Buttons diff --git a/docs/plugins/development/staged-changes.md b/docs/plugins/development/staged-changes.md deleted file mode 100644 index a8fd1d232..000000000 --- a/docs/plugins/development/staged-changes.md +++ /dev/null @@ -1,39 +0,0 @@ -# Staged Changes - -!!! danger "Deprecated Feature" - This feature has been deprecated in NetBox v4.2 and will be removed in a future release. Please consider using the [netbox-branching plugin](https://github.com/netboxlabs/netbox-branching), which provides much more robust functionality. - -NetBox provides a programmatic API to stage the creation, modification, and deletion of objects without actually committing those changes to the active database. This can be useful for performing a "dry run" of bulk operations, or preparing a set of changes for administrative approval, for example. - -To begin staging changes, first create a [branch](../../models/extras/branch.md): - -```python -from extras.models import Branch - -branch1 = Branch.objects.create(name='branch1') -``` - -Then, activate the branch using the `checkout()` context manager and begin making your changes. This initiates a new database transaction. - -```python -from extras.models import Branch -from netbox.staging import checkout - -branch1 = Branch.objects.get(name='branch1') -with checkout(branch1): - Site.objects.create(name='New Site', slug='new-site') - # ... -``` - -Upon exiting the context, the database transaction is automatically rolled back and your changes recorded as [staged changes](../../models/extras/stagedchange.md). Re-entering a branch will trigger a new database transaction and automatically apply any staged changes associated with the branch. - -To apply the changes within a branch, call the branch's `commit()` method: - -```python -from extras.models import Branch - -branch1 = Branch.objects.get(name='branch1') -branch1.commit() -``` - -Committing a branch is an all-or-none operation: Any exceptions will revert the entire set of changes. After successfully committing a branch, all its associated StagedChange objects are automatically deleted (however the branch itself will remain and can be reused). diff --git a/docs/plugins/development/views.md b/docs/plugins/development/views.md index e3740de59..43cc0ce82 100644 --- a/docs/plugins/development/views.md +++ b/docs/plugins/development/views.md @@ -198,6 +198,7 @@ Plugins can inject custom content into certain areas of core NetBox views. This | Method | View | Description | |---------------------|-------------|-----------------------------------------------------| +| `head()` | All | Custom HTML `` block includes | | `navbar()` | All | Inject content inside the top navigation bar | | `list_buttons()` | List view | Add buttons to the top of the page | | `buttons()` | Object view | Add buttons to the top of the page | diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md index d996224c1..0d0b10092 100644 --- a/docs/release-notes/index.md +++ b/docs/release-notes/index.md @@ -10,6 +10,15 @@ 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.3](./version-4.3.md) (May 2025) + +* Module Type Profiles & Custom Attributes ([#19002](https://github.com/netbox-community/netbox/issues/19002)) +* Reusable Table Configurations ([#14591](https://github.com/netbox-community/netbox/issues/14591)) +* Option to Treat IP Ranges as Fully Populated ([#9763](https://github.com/netbox-community/netbox/issues/9763)) +* Hierarchical Device Roles ([#18245](https://github.com/netbox-community/netbox/issues/18245)) +* Periodic Synchronization of Data Sources ([#18287](https://github.com/netbox-community/netbox/issues/18287)) +* Proxy Routing ([#18627](https://github.com/netbox-community/netbox/issues/18627)) + #### [Version 4.2](./version-4.2.md) (January 2025) * Assign Multiple MAC Addresses per Interface ([#4867](https://github.com/netbox-community/netbox/issues/4867)) diff --git a/docs/release-notes/version-2.1.md b/docs/release-notes/version-2.1.md index 7ec172b1f..0f84c80d9 100644 --- a/docs/release-notes/version-2.1.md +++ b/docs/release-notes/version-2.1.md @@ -150,5 +150,5 @@ The [NAPALM automation](https://github.com/napalm-automation/napalm) library pro * Modified the interface serializer to include three discrete fields relating to connections: `is_connected` (boolean), `interface_connection`, and `circuit_termination` * Added two new fields to the inventory item serializer: `asset_tag` and `description` * Added "wireless" to interface type filter (in addition to physical, virtual, and LAG) -* Added a new endpoint at /api/ipam/prefixes//available-ips/ to retrieve or create available IPs within a prefix +* Added a new endpoint at /api/ipam/prefixes//available-ips/ to retrieve or create available IPs within a prefix * Extended `parent_device` on DeviceSerializer to include the `url` and `display_name` of the parent Device, and the `url` of the DeviceBay diff --git a/docs/release-notes/version-4.2.md b/docs/release-notes/version-4.2.md index f0ad3766c..45484f406 100644 --- a/docs/release-notes/version-4.2.md +++ b/docs/release-notes/version-4.2.md @@ -1,13 +1,252 @@ # NetBox v4.2 -## v4.2-beta1 (2024-12-02) +## v4.2.9 (2025-04-30) -!!! danger "Not for Production Use" - This is a beta release of NetBox intended for testing and evaluation. **Do not use this software in production.** Also be aware that no upgrade path is provided to future releases. +### Enhancements + +* [#17151](https://github.com/netbox-community/netbox/issues/17151) - Display circuit type with background color in circuits list +* [#17319](https://github.com/netbox-community/netbox/issues/17319) - Improve layout of component template edit forms +* [#17405](https://github.com/netbox-community/netbox/issues/17405) - Display plugin icons in plugins list +* [#18215](https://github.com/netbox-community/netbox/issues/18215) - Link to script results list from script history +* [#18334](https://github.com/netbox-community/netbox/issues/18334) - Add region, site group, site, location, and rack filters for modules +* [#18982](https://github.com/netbox-community/netbox/issues/18982) - Reference rack as related object in changelog records for rack reservations +* [#18989](https://github.com/netbox-community/netbox/issues/18989) - List virtual circuits under provider view +* [#19110](https://github.com/netbox-community/netbox/issues/19110) - Enable filtering devices and virtual machines by primary IP address +* [#19358](https://github.com/netbox-community/netbox/issues/19358) - Move release info from footer to the navigation menu + +### Bug Fixes + +* [#15739](https://github.com/netbox-community/netbox/issues/15739) - Account for parallel cables when calculating total path length +* [#15971](https://github.com/netbox-community/netbox/issues/15971) - Preserve "none" selection in filter form fields +* [#16238](https://github.com/netbox-community/netbox/issues/16238) - Fix styling for white, gray, and black custom link buttons +* [#17613](https://github.com/netbox-community/netbox/issues/17613) - Fix layout of object view content on mobile +* [#17676](https://github.com/netbox-community/netbox/issues/17676) - Fix support for module bay creation when bulk importing module types +* [#18706](https://github.com/netbox-community/netbox/issues/18706) - Fix validation for VLANs assigned to both a group and a site +* [#18717](https://github.com/netbox-community/netbox/issues/18717) - Ensure change logs populated for many-to-one changes +* [#19117](https://github.com/netbox-community/netbox/issues/19117) - Avoid `AttributeError` exception when bulk import objects which have a multi-object custom field with a default value +* [#19204](https://github.com/netbox-community/netbox/issues/19204) - Improve JSON serialization support for data returned by a custom script +* [#19217](https://github.com/netbox-community/netbox/issues/19217) - Ensure static assets for the debug toolbar are installed even if `DEBUG` is false +* [#19228](https://github.com/netbox-community/netbox/issues/19228) - Fix ordering of custom scripts to avoid `NoReverseMatch` exception +* [#19229](https://github.com/netbox-community/netbox/issues/19229) - Fix `ValueError` exception when attempting to nullify interface mode when a VLAN is assigned +* [#19275](https://github.com/netbox-community/netbox/issues/19275) - `type` field should not be required when bulk editing interfaces +* [#19279](https://github.com/netbox-community/netbox/issues/19279) - `status` field should not be required when bulk editing inventory items +* [#19281](https://github.com/netbox-community/netbox/issues/19281) - Fix form validation failure when attempting to create a service from a service template +* [#19320](https://github.com/netbox-community/netbox/issues/19320) - Include Q-in-Q VLAN (if any) in VM interface details +* [#19322](https://github.com/netbox-community/netbox/issues/19322) - Correct URL paths for bulk import views +* [#19346](https://github.com/netbox-community/netbox/issues/19346) - Ensure all redirect URLs are validated before use + +--- + +## v4.2.8 (2025-04-22) + +### Enhancements + +* [#17136](https://github.com/netbox-community/netbox/issues/17136) - Introduce the `--readonly` flag on upgrade script +* [#17908](https://github.com/netbox-community/netbox/issues/17908) - Add trace buttons to terminations under cable view +* [#18879](https://github.com/netbox-community/netbox/issues/18879) - Enable filtering prefixes by group of assigned VLAN +* [#18976](https://github.com/netbox-community/netbox/issues/18976) - Include FHRP group name on interface lists +* [#18978](https://github.com/netbox-community/netbox/issues/18978) - Add 802.1Q mode to interface filter form +* [#19038](https://github.com/netbox-community/netbox/issues/19038) - Show count of related VLAN groups under cluster view +* [#19040](https://github.com/netbox-community/netbox/issues/19040) - Add "copy to clipboard" button for rendered config +* [#19056](https://github.com/netbox-community/netbox/issues/19056) - Enable filtering devices by location slug +* [#19196](https://github.com/netbox-community/netbox/issues/19196) - Add filtering by VLAN translation policy to interface filter forms + +### Bug Fixes + +* [#18500](https://github.com/netbox-community/netbox/issues/18500) - `prepare_cloned_fields()` should validate cloning support on model +* [#18669](https://github.com/netbox-community/netbox/issues/18669) - Ensure default custom field values are respected when creating objects via the REST API +* [#18881](https://github.com/netbox-community/netbox/issues/18881) - Include missing related object counts under certain views +* [#18955](https://github.com/netbox-community/netbox/issues/18955) - Omit "clear" button on required choice fields +* [#18959](https://github.com/netbox-community/netbox/issues/18959) - Preserve ordering of terminations in cable traces +* [#18961](https://github.com/netbox-community/netbox/issues/18961) - Virtual chassis form should exclude members of other VCs when adding members +* [#19166](https://github.com/netbox-community/netbox/issues/19166) - Fix custom field choices bulk import support for `base_choices` +* [#19189](https://github.com/netbox-community/netbox/issues/19189) - The `load_yaml()` convenience method on BaseScript should use SafeLoader +* [#19195](https://github.com/netbox-community/netbox/issues/19195) - Language cookie should respect `SESSION_COOKIE_SECURE` value +* [#19230](https://github.com/netbox-community/netbox/issues/19230) - Allow label reuse when creating multiple components from a pattern +* [#19268](https://github.com/netbox-community/netbox/issues/19268) - Restore editing conflict protection for several object forms + +--- + +## v4.2.7 (2025-04-10) + +### Enhancements + +* [#16144](https://github.com/netbox-community/netbox/issues/16144) - Add support for plugin models to GetReturnURLMixin +* [#18138](https://github.com/netbox-community/netbox/issues/18138) - Enable filtering of ObjectVar and MultiObjectVar input selections for custom fields +* [#18656](https://github.com/netbox-community/netbox/issues/18656) - Enable FHRP group assignment when bulk importing IP addresses +* [#18980](https://github.com/netbox-community/netbox/issues/18980) - Optimize bulk updates of custom field values when custom fields are added/removed +* [#19018](https://github.com/netbox-community/netbox/issues/19018) - Add MoCA interface type + +### Bug Fixes + +* [#18553](https://github.com/netbox-community/netbox/issues/18553) - Avoid clearing site of assigned virtual machines when editing a cluster +* [#18738](https://github.com/netbox-community/netbox/issues/18738) - Respect declared ordering of custom scripts within a module +* [#18895](https://github.com/netbox-community/netbox/issues/18895) - Fix GraphQL support for interfaces which terminate virtual circuits +* [#18904](https://github.com/netbox-community/netbox/issues/18904) - Add missing tags column to config contexts table +* [#18964](https://github.com/netbox-community/netbox/issues/18964) - Fix "select all" behavior on object lists +* [#18965](https://github.com/netbox-community/netbox/issues/18965) - "Run script" button should respect default commit toggle for custom scripts +* [#18991](https://github.com/netbox-community/netbox/issues/18991) - Fix cable path tracing for pass-through ports in REST API +* [#18999](https://github.com/netbox-community/netbox/issues/18999) - Fix filtering of inventory items with no manufacturer in GraphQL API +* [#19021](https://github.com/netbox-community/netbox/issues/19021) - Preserve JSONField stylign when `help_text` is passed +* [#19023](https://github.com/netbox-community/netbox/issues/19023) - `get_field_value()` should honor null values on bound form fields +* [#19030](https://github.com/netbox-community/netbox/issues/19030) - Prevent pagination buttons from overlapping bulk action buttons on object lists +* [#19041](https://github.com/netbox-community/netbox/issues/19041) - Fix `IndexError` exception when creating multiple front ports with a label +* [#19092](https://github.com/netbox-community/netbox/issues/19092) - Fix clearing of scope field when bulk editing prefixes +* [#19122](https://github.com/netbox-community/netbox/issues/19122) - Fix styling of server error page + +--- + +## v4.2.6 (2025-03-21) + +### Enhancements + +* [#17503](https://github.com/netbox-community/netbox/issues/17503) - Add rack title above rack on rack detail view +* [#17686](https://github.com/netbox-community/netbox/issues/17686) - Add config option for disk space divisor +* [#18579](https://github.com/netbox-community/netbox/issues/18579) - Update filtersets and filter forms to include contact filters where missing +* [#18744](https://github.com/netbox-community/netbox/issues/18744) - Ensure contact link in tables is hyperlinked +* [#18816](https://github.com/netbox-community/netbox/issues/18816) - Add FC/UPC, FC/APC and FC/PC port types +* [#18880](https://github.com/netbox-community/netbox/issues/18880) - Delay enqueuing background tasks until DB transaction is committed to avoid race condition +* [#18939](https://github.com/netbox-community/netbox/issues/18939) - Support site group search for ASNs + +### Bug Fixes + +* [#18409](https://github.com/netbox-community/netbox/issues/18409) - Eliminate N+1 issue by adding generic prefetch operation to Interface API endpoint +* [#18557](https://github.com/netbox-community/netbox/issues/18557) - Update JSONField to enclose bare string values in quotes +* [#18582](https://github.com/netbox-community/netbox/issues/18582) - Fix prefix bulk import with associated VLAN and conflicting VLAN IDs +* [#18742](https://github.com/netbox-community/netbox/issues/18742) - Ensure location list and detail views show related VLAN group information +* [#18782](https://github.com/netbox-community/netbox/issues/18782) - Ensure misconfigured object list widgets on the dashboard now degrade gracefully +* [#18833](https://github.com/netbox-community/netbox/issues/18833) - Fix inventory item bulk edit to ensure that component name and type are both validated Ensure +* [#18838](https://github.com/netbox-community/netbox/issues/18838) - Ensure that local context data correctly rejects falsy values +* [#18845](https://github.com/netbox-community/netbox/issues/18845) - Restore default sort behavior of name column on devices list view +* [#18863](https://github.com/netbox-community/netbox/issues/18863) - Exempt MPTT-based models from ordering fix introduced in #18279 +* [#18869](https://github.com/netbox-community/netbox/issues/18869) - Ensure numeric conversion helper always return a clean decimal value +* [#18872](https://github.com/netbox-community/netbox/issues/18872) - Ensure that `kind` is a required field when making journal entries +* [#18884](https://github.com/netbox-community/netbox/issues/18884) - Ensure tag deserialization is handled correctly +* [#18887](https://github.com/netbox-community/netbox/issues/18887) - Allow VM interface objects to be set on prefix object-type custom field +* [#18926](https://github.com/netbox-community/netbox/issues/18926) - Fix icon displayed for GitHub authentication on login page +* [#18928](https://github.com/netbox-community/netbox/issues/18928) - Support cascading deletions when cleaning up expired changelog records +* [#18933](https://github.com/netbox-community/netbox/issues/18933) - Allow filtering VLAN groups by associated site groups +* [#18944](https://github.com/netbox-community/netbox/issues/18944) - Ensure clearing "Widget type" field when adding widgets to dashboard does not cause a "ValueError: Unregistered widget class" error +* [#18949](https://github.com/netbox-community/netbox/issues/18949) - Add missing contacts property to GraphQL types where the associated model has a connection to a contact + +--- + +## v4.2.5 (2025-03-06) + +### Enhancements + +* [#17357](https://github.com/netbox-community/netbox/issues/17357) - Use VirtualChassis name as fallback for unnamed devices +* [#17542](https://github.com/netbox-community/netbox/issues/17542) - Add contact assignments to VPN tunnels +* [#17944](https://github.com/netbox-community/netbox/issues/17944) - Allow script inputs to be filtered on ObjectVar and MultiObjectVar selections +* [#18024](https://github.com/netbox-community/netbox/issues/18024) - Add permalink URL pattern to match a custom script by module and class name +* [#18141](https://github.com/netbox-community/netbox/issues/18141) - Support "Quick Add" for plugins +* [#18403](https://github.com/netbox-community/netbox/issues/18403) - Improve performance of job list views +* [#18693](https://github.com/netbox-community/netbox/issues/18693) - Support setting VLAN translation on bulk edit of interfaces +* [#18772](https://github.com/netbox-community/netbox/issues/18772) - Add "type" filter for virtual circuits +* [#18774](https://github.com/netbox-community/netbox/issues/18774) - Add tooltip preview of tag descriptions when hovering over tags + +### Bug Fixes + +* [#15016](https://github.com/netbox-community/netbox/issues/15016) - Prevent AssertionError when adding multiple devices "mid-span" in a cable trace +* [#15924](https://github.com/netbox-community/netbox/issues/15924) - Prevent setting tagged VLANs on interfaces with mode: tagged-all +* [#17488](https://github.com/netbox-community/netbox/issues/17488) - Ensure VLANGroup.vid_ranges shows up in API results +* [#17709](https://github.com/netbox-community/netbox/issues/17709) - Allow primary key for nested models in OpenAPI request schemas +* [#17796](https://github.com/netbox-community/netbox/issues/17796) - Fix IndexError on "Create & Add Another" operation on custom field choices +* [#18605](https://github.com/netbox-community/netbox/issues/18605) - Limit VLAN selection dropdown to choices appropriate to site +* [#18722](https://github.com/netbox-community/netbox/issues/18722) - Improve UI feedback on failed script execution +* [#18729](https://github.com/netbox-community/netbox/issues/18729) - Fix unpredictable ordering on querysets with annotations/groupings +* [#18753](https://github.com/netbox-community/netbox/issues/18753) - Prevent webhooks from being triggered on a script dry-run +* [#18758](https://github.com/netbox-community/netbox/issues/18758) - Fix FieldError when sorting by account count field in providers list +* [#18768](https://github.com/netbox-community/netbox/issues/18768) - Fix removing a secondary MAC address from an interface + +--- + +## v4.2.4 (2025-02-21) + +### Enhancements + +* [#17309](https://github.com/netbox-community/netbox/issues/17309) - Omit empty counts in related object tables +* [#18277](https://github.com/netbox-community/netbox/issues/18277) - Improve multi-table inheritance in serialization of change-logged models +* [#18286](https://github.com/netbox-community/netbox/issues/18286) - Add more job duration choices +* [#18357](https://github.com/netbox-community/netbox/issues/18357) - Display author name in plugin list for locally installed plugins +* [#18408](https://github.com/netbox-community/netbox/issues/18408) - Add Paused status for virtual machines +* [#18584](https://github.com/netbox-community/netbox/issues/18584) - Add rack type column to manufacturer list + +### Bug Fixes + +* [#17436](https://github.com/netbox-community/netbox/issues/17436) - Fix {module} replacement in module bays +* [#18013](https://github.com/netbox-community/netbox/issues/18013) - Limit object type to selected object in change log filter +* [#18241](https://github.com/netbox-community/netbox/issues/18241) - Default logging level of custom scripts changed to INFO +* [#18247](https://github.com/netbox-community/netbox/issues/18247) - Fix visibility of disabled cable paths in dark mode +* [#18480](https://github.com/netbox-community/netbox/issues/18480) - Clean data passed to script in runscript command +* [#18555](https://github.com/netbox-community/netbox/issues/18555) - Add default get_absolute_url method to plugin models +* [#18585](https://github.com/netbox-community/netbox/issues/18585) - Fix filtering circuits by location +* [#18593](https://github.com/netbox-community/netbox/issues/18593) - Fix "Create & Add Another" IP Address workflow +* [#18594](https://github.com/netbox-community/netbox/issues/18594) - Enable sorting by ASN count on site and provider lists +* [#18619](https://github.com/netbox-community/netbox/issues/18619) - Ensure shift-click selection selects only visible list items +* [#18674](https://github.com/netbox-community/netbox/issues/18674) - Preserve form values when selecting speed on circuit termination + +--- + +## v4.2.3 (2025-02-04) + +### Enhancements + +* [#18518](https://github.com/netbox-community/netbox/issues/18518) - Add a "hostname" `` tag to the page header + +### Bug Fixes + +* [#18497](https://github.com/netbox-community/netbox/issues/18497) - Fix unhandled `FieldDoesNotExist` exception when search results include virtual circuit +* [#18433](https://github.com/netbox-community/netbox/issues/18433) - Fix MAC address not shown as "primary for interface" in MAC address detail view +* [#18154](https://github.com/netbox-community/netbox/issues/18154) - Allow anonymous users to change default table preferences +* [#18515](https://github.com/netbox-community/netbox/issues/18515) - Fix Django `collectstatic` management command in debug mode with Redis not running +* [#18456](https://github.com/netbox-community/netbox/issues/18456) - Avoid duplicate MAC Address column in interface tables +* [#18447](https://github.com/netbox-community/netbox/issues/18447) - Fix `FieldError` exception when sorting interface tables on MAC Address columns +* [#18438](https://github.com/netbox-community/netbox/issues/18438) - Improve performance in IPAM migration `0072_prefix_cached_relations` when upgrading from v4.1 or earlier +* [#18436](https://github.com/netbox-community/netbox/issues/18436) - Reset primary MAC address when unassigning MAC address from interface +* [#18181](https://github.com/netbox-community/netbox/issues/18181) - Fix "Create & Add Another" workflow when adding IP addresses to interfaces + +--- + +## v4.2.2 (2025-01-17) + +### Bug Fixes + +* [#18336](https://github.com/netbox-community/netbox/issues/18336) - Validate new rack height against installed devices when changing a rack's type +* [#18350](https://github.com/netbox-community/netbox/issues/18350) - Fix `FieldDoesNotExist` exception when global search results include a circuit termination +* [#18353](https://github.com/netbox-community/netbox/issues/18353) - Disable fetching of plugin catalog data when `ISOLATED_DEPLOYMENT` is enabled +* [#18362](https://github.com/netbox-community/netbox/issues/18362) - Avoid transmitting census data on every worker restart +* [#18363](https://github.com/netbox-community/netbox/issues/18363) - Fix support for assigning a MAC address to an interface via the REST API +* [#18368](https://github.com/netbox-community/netbox/issues/18368) - Restore missing attributes from REST API serializer for MAC addresses (`tags`, `created`, `last_updated`, and custom fields) +* [#18369](https://github.com/netbox-community/netbox/issues/18369) - Fix `TypeError` exception when rendering the system configuration view with one or more custom classes defined under `PROTECTION_RULES` +* [#18373](https://github.com/netbox-community/netbox/issues/18373) - Fix `AttributeError` exception when attempting to assign host devices to a cluster +* [#18376](https://github.com/netbox-community/netbox/issues/18376) - Fix the display of tagged VLANs in interfaces list for Q-in-Q interfaces +* [#18379](https://github.com/netbox-community/netbox/issues/18379) - Ensure RSS feed dashboard widget content is sanitized +* [#18392](https://github.com/netbox-community/netbox/issues/18392) - Virtual machines should not inherit config contexts assigned to locations +* [#18400](https://github.com/netbox-community/netbox/issues/18400) - Fix support for `STORAGE_BACKEND` configuration parameter +* [#18406](https://github.com/netbox-community/netbox/issues/18406) - Scope column headers in object lists should not be orderable + +--- + +## v4.2.1 (2025-01-08) + +### Bug Fixes + +* [#18282](https://github.com/netbox-community/netbox/issues/18282) - Fix ordering of prefixes list by assigned VLAN +* [#18314](https://github.com/netbox-community/netbox/issues/18314) - Fix KeyError exception when rendering pre-saved dashboard (`requires_internet` missing) +* [#18316](https://github.com/netbox-community/netbox/issues/18316) - Fix AttributeError exception when global search results include prefixes and/or clusters +* [#18318](https://github.com/netbox-community/netbox/issues/18318) - Correct navigation breadcrumbs for module type UI view +* [#18324](https://github.com/netbox-community/netbox/issues/18324) - Correct filtering for certain related object listings +* [#18329](https://github.com/netbox-community/netbox/issues/18329) - Address upstream bug in GraphQL API where only one primary IP address is returned within a device/VM list + +--- + +## v4.2.0 (2025-01-06) ### Breaking Changes * Support for the Django admin UI has been completely removed. (The Django admin UI was disabled by default in NetBox v4.0.) +* This release drops support for PostgreSQL 12. PostgreSQL 13 or later is required to run this release. * NetBox has adopted collation-based natural ordering for many models. This may alter the order in which some objects are listed by default. * Automatic redirects from pre-v4.1 UI views for virtual disks have been removed. * The `site` and `provider_network` foreign key fields on `circuits.CircuitTermination` have been replaced by the `termination` generic foreign key. diff --git a/docs/release-notes/version-4.3.md b/docs/release-notes/version-4.3.md new file mode 100644 index 000000000..07d6b66f1 --- /dev/null +++ b/docs/release-notes/version-4.3.md @@ -0,0 +1,134 @@ +## v4.3.0 (2025-05-01) + +### Breaking Changes + +* The GraphQL API Now uses an advanced syntax for filtering, to enable e.g. logical AND/OR filtering and custom field lookups. +* PostgreSQL 13 is no longer supported. NetBox v4.3 requires PostgreSQL 14.0 or later. +* The `ALLOW_TOKEN_RETRIEVAL` configuration parameter now defaults to False. +* The `device` and `virtual_machine` foreign keys on the Service model have been replaced with a generic `parent` relationship to support the assignment of services to FHRP groups as well. +* The `group` foreign key on the Contact model has been replaced with a many-to-many `groups` field. +* `django-storages` is now a required dependency. (It will be installed automatically on upgrade.) +* PluginTemplateExtension no longer supports registration via the singular `model` attribute (use `models` instead). +* The legacy staged changes functionality has been removed. + +### New Features + +#### Module Type Profiles & Custom Attributes ([#19002](https://github.com/netbox-community/netbox/issues/19002)) + +The new [module type profile](../models/dcim/moduletypeprofile.md) model enables users to declare custom profiles for module types, with the ability to define custom attributes for each profile according to its functional role. For example, a CPU module type might declare architecture and clock speed attributes; a hard disk profile might declare attributes for type and speed. + +Attributes can be declared on each profile using [JSON schema](https://json-schema.org/), which allows for attributes to be declared as strings (text), integers, decimals, booleans, or choice fields. Profile attributes render as individual form fields when modifying a module type. Several profiles have been included by default to serve as examples, however these may be modified or removed. + +#### Reusable Table Configurations ([#14591](https://github.com/netbox-community/netbox/issues/14591)) + +After modifying the displayed columns and/or ordering for a specific object table in the user interface, users now have the option to save that configuration so that it can be reused in the future. Similar to saved filters, table configs can be shared with other users to easily replicate table layouts crafted to serve specific use cases. + +#### Option to Treat IP Ranges as Fully Populated ([#9763](https://github.com/netbox-community/netbox/issues/9763)) + +A new `mark_populated` boolean field has been added to the IPRange model. If set to true, NetBox will consider the IP range to be fully populated, and will not permit the creation of individual IP addresses within the range. For example, you might defer the management of an IP range to an external DHCP server, and wish for NetBox to treat the range as a opaque monolithic block for planning and allocation purposes. + +#### Hierarchical Device Roles ([#18245](https://github.com/netbox-community/netbox/issues/18245)) + +Device roles can now be arranged hierarchically, with one role optionally serving as a parent to one or more child roles. For example, you might wish to create a generic "Server" role for devices with "Application Server" and "Database Server" roles beneath it. A device could then be assigned to any of these three roles. + +#### Periodic Synchronization of Data Sources ([#18287](https://github.com/netbox-community/netbox/issues/18287)) + +Data sources can now be configured to synchronize automatically at a specified interval, as indicated by the new `sync_interval` field. No additional system configuration is necessary to support this functionality; background jobs will be scheduled automatically by the RQ worker process. + +#### Proxy Routing ([#18627](https://github.com/netbox-community/netbox/issues/18627)) + +User can now declare one or more proxy routers via the `PROXY_ROUTERS` configuration parameter to control the use of specific proxy servers for various outbound connections. For example, it is now possible to configure NetBox to use different proxies based on the type of outbound traffic or its destination. + +### Enhancements + +* [#7598](https://github.com/netbox-community/netbox/issues/7598) - Adopt advanced query filtering in GraphQL API to support filtering by custom fields +* [#8423](https://github.com/netbox-community/netbox/issues/8423) - Enable assigning services to FHRP groups +* [#15842](https://github.com/netbox-community/netbox/issues/15842) - Introduce the `LOGIN_FORM_HIDDEN` configuration parameter +* [#16224](https://github.com/netbox-community/netbox/issues/16224) - Implement pagination support for the GraphQL API +* [#17170](https://github.com/netbox-community/netbox/issues/17170) - Enable the assignment of a contact to multiple contact groups +* [#17443](https://github.com/netbox-community/netbox/issues/17443) - Add a `file_name` field to the export template model +* [#17602](https://github.com/netbox-community/netbox/issues/17602) - Add a `comments` field to all nested group models (Region, SiteGroup, Location, ContactGroup, TenantGroup, and WirelessLANGroup) +* [#17608](https://github.com/netbox-community/netbox/issues/17608) - Add a `status` field to the L2VPN model +* [#17653](https://github.com/netbox-community/netbox/issues/17653) - Enable declaring Jinja environment parameters on export templates (similar to config templates) +* [#17793](https://github.com/netbox-community/netbox/issues/17793) - Introduce a REST API endpoint for tagged objects (`/api/extras/tagged-objects/`) +* [#17841](https://github.com/netbox-community/netbox/issues/17841) - Add a `weight` field to the Tag model to influence ordering +* [#18296](https://github.com/netbox-community/netbox/issues/18296) - Add a `tenant` field to the VLAN group model +* [#18352](https://github.com/netbox-community/netbox/issues/18352) - Add a `status` field to the power outlet model +* [#18417](https://github.com/netbox-community/netbox/issues/18417) - Add an `outer_height` field to the rack & rack type models +* [#18535](https://github.com/netbox-community/netbox/issues/18535) - The presence of incompatible plugins will no longer prevent NetBox from starting +* [#18780](https://github.com/netbox-community/netbox/issues/18780) - Introduce `DATABASES` and `DATABASE_ROUTERS` configuration parameters to enable defining connections to external databases (e.g. for plugins) +* [#18783](https://github.com/netbox-community/netbox/issues/18783) - Enable filtering all applicable models by tag ID +* [#18785](https://github.com/netbox-community/netbox/issues/18785) - Enable custom choices for rack, device, and module airflow +* [#18896](https://github.com/netbox-community/netbox/issues/18896) - Enable the use of remote storage for custom scripts + +### Plugins + +* [#16630](https://github.com/netbox-community/netbox/issues/16630) - Plugins can now inject content within the HTML `` block via the new `plugin_head()` method on PluginTemplateExtension +* [#17424](https://github.com/netbox-community/netbox/issues/17424) - Extend ViewTab with a `visible` argument to control tab rendering +* [#17857](https://github.com/netbox-community/netbox/issues/17857) - Added a `release_track` attribute to PluginConfig +* [#18305](https://github.com/netbox-community/netbox/issues/18305) - Introduce plugin support for ContactsMixin +* [#19073](https://github.com/netbox-community/netbox/issues/19073) - Allow installed plugins to be omitted from the plugins list + +### Other Changes + +* [#18071](https://github.com/netbox-community/netbox/issues/18071) - Removed legacy staged changed functionality in favor of the [netbox-branching](https://github.com/netboxlabs/netbox-branching) plugin +* [#18072](https://github.com/netbox-community/netbox/issues/18072) - Drop support for the singular `model` attribute on PluginTemplateExtension (use `models` instead) +* [#18191](https://github.com/netbox-community/netbox/issues/18191) - Remove redundant PostgreSQL indexes +* [#18236](https://github.com/netbox-community/netbox/issues/18236) - Upgrade the HTMX library to v2.0 +* [#18540](https://github.com/netbox-community/netbox/issues/18540) - Operational plugins are now recorded in the application registry +* [#18623](https://github.com/netbox-community/netbox/issues/18623) - Upgrade the Tabler CSS theme to v1.2 +* [#18743](https://github.com/netbox-community/netbox/issues/18743) - Upgrade Django to v5.2 +* [#18751](https://github.com/netbox-community/netbox/issues/18751) - Change the default value for `ALLOW_TOKEN_RETRIEVAL` to False +* [#18808](https://github.com/netbox-community/netbox/issues/18808) - Squashed migration dependencies have been altered to rectify an issue with Django's `sqlmigrate` management command +* [#18820](https://github.com/netbox-community/netbox/issues/18820) - PostgreSQL 13 is no longer supported +* [#19004](https://github.com/netbox-community/netbox/issues/19004) - The use of inventory items has been deprecated in favor of modules. Inventory items and roles may be removed in a future NetBox release. + +### REST API Changes + +* Added the following endpoints: + * `/api/extras/table-configs/` + * `/api/extras/tagged-objects/` + * `/api/dcim/module-type-profiles/` +* core.DataSource + * Added the optional `sync_interval` field +* dcim.DeviceRole + * Added the optional `parent` recursive foreign key field to effect hierarchical ordering + * Added a `comments` field +* dcim.Location + * Added a `comments` field +* dcim.ModuleType + * Added the optional `profile` foreign key to the new ModuleTypeProfile model +* dcim.PowerOutlet + * Added a `status` field +* dcim.Rack + * Added the optional `outer_height` field +* dcim.RackType + * Added the optional `outer_height` field +* dcim.Region + * Added a `comments` field +* dcim.SiteGroup + * Added a `comments` field +* extras.ConfigTemplate + * Added optional fields `mime_type`, `file_name`, `file_extension` and `as_attachment` +* extras.ExportTemplate + * Added optional fields `file_name` and `environment_params` (JSON) +* extras.Tag + * Added a `weight` field +* ipam.IPRange + * Added a `mark_populaed` boolean field +* ipam.L2VPN + * Added a `status` field +* ipam.Service + * Removed the `device` and `virtual_machine` foreign key fields + * Added the `parent_object_type`, `parent_object_id`, and (read-only) `parent` fields +* ipam.VLANGroup + * Added the optional `tenant` foreign key field +* tenancy.Contact + * Removed the `group` foreign key field + * Added the `groups` many-to-many field +* tenancy.ContactGroup + * Added a `comments` field +* tenancy.TenantGroup + * Added a `comments` field +* wireless.WirelessLANGroup + * Added a `comments` field diff --git a/mkdocs.yml b/mkdocs.yml index f870b69d6..27526bd26 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -28,12 +28,7 @@ plugins: - mkdocstrings: handlers: python: - setup_commands: - - import os - - import django - - os.chdir('netbox/') - - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings") - - django.setup() + paths: ["netbox"] options: heading_level: 3 members_order: source @@ -54,6 +49,7 @@ markdown_extensions: - admonition - attr_list - footnotes + - md_in_html - pymdownx.emoji: emoji_index: !!python/name:material.extensions.emoji.twemoji emoji_generator: !!python/name:material.extensions.emoji.to_svg @@ -64,6 +60,8 @@ markdown_extensions: format: !!python/name:pymdownx.superfences.fence_code_format - pymdownx.tabbed: alternate_style: true +not_in_nav: | + /index.md nav: - Introduction: 'introduction.md' - Features: @@ -150,7 +148,6 @@ nav: - GraphQL API: 'plugins/development/graphql-api.md' - Background Jobs: 'plugins/development/background-jobs.md' - 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: @@ -176,6 +173,7 @@ nav: - Provider Network: 'models/circuits/providernetwork.md' - Virtual Circuit: 'models/circuits/virtualcircuit.md' - Virtual Circuit Termination: 'models/circuits/virtualcircuittermination.md' + - Virtual Circuit Type: 'models/circuits/virtualcircuittype.md' - Core: - DataFile: 'models/core/datafile.md' - DataSource: 'models/core/datasource.md' @@ -205,6 +203,7 @@ nav: - ModuleBay: 'models/dcim/modulebay.md' - ModuleBayTemplate: 'models/dcim/modulebaytemplate.md' - ModuleType: 'models/dcim/moduletype.md' + - ModuleTypeProfile: 'models/dcim/moduletypeprofile.md' - Platform: 'models/dcim/platform.md' - PowerFeed: 'models/dcim/powerfeed.md' - PowerOutlet: 'models/dcim/poweroutlet.md' @@ -225,7 +224,6 @@ nav: - VirtualDeviceContext: 'models/dcim/virtualdevicecontext.md' - Extras: - Bookmark: 'models/extras/bookmark.md' - - Branch: 'models/extras/branch.md' - ConfigContext: 'models/extras/configcontext.md' - ConfigTemplate: 'models/extras/configtemplate.md' - CustomField: 'models/extras/customfield.md' @@ -238,8 +236,8 @@ nav: - Notification: 'models/extras/notification.md' - NotificationGroup: 'models/extras/notificationgroup.md' - SavedFilter: 'models/extras/savedfilter.md' - - StagedChange: 'models/extras/stagedchange.md' - Subscription: 'models/extras/subscription.md' + - TableConfig: 'models/extras/tableconfig.md' - Tag: 'models/extras/tag.md' - Webhook: 'models/extras/webhook.md' - IPAM: @@ -311,6 +309,7 @@ nav: - git Cheat Sheet: 'development/git-cheat-sheet.md' - Release Notes: - Summary: 'release-notes/index.md' + - Version 4.3: 'release-notes/version-4.3.md' - Version 4.2: 'release-notes/version-4.2.md' - Version 4.1: 'release-notes/version-4.1.md' - Version 4.0: 'release-notes/version-4.0.md' diff --git a/netbox/account/migrations/0001_initial.py b/netbox/account/migrations/0001_initial.py index 72c079565..badd459ca 100644 --- a/netbox/account/migrations/0001_initial.py +++ b/netbox/account/migrations/0001_initial.py @@ -8,7 +8,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('users', '0004_netboxgroup_netboxuser'), + ('users', '0002_squashed_0004'), ] operations = [ diff --git a/netbox/account/views.py b/netbox/account/views.py index 05f40df3f..f28d5eff5 100644 --- a/netbox/account/views.py +++ b/netbox/account/views.py @@ -12,7 +12,7 @@ from django.shortcuts import get_object_or_404, redirect from django.shortcuts import render, resolve_url from django.urls import reverse from django.utils.decorators import method_decorator -from django.utils.http import url_has_allowed_host_and_scheme, urlencode +from django.utils.http import urlencode from django.utils.translation import gettext_lazy as _ from django.views.decorators.debug import sensitive_post_parameters from django.views.generic import View @@ -28,6 +28,8 @@ from netbox.config import get_config from netbox.views import generic from users import forms, tables from users.models import UserConfig +from utilities.request import safe_for_redirect +from utilities.string import remove_linebreaks from utilities.views import register_model_view @@ -89,10 +91,12 @@ class LoginView(View): if request.user.is_authenticated: logger = logging.getLogger('netbox.auth.login') return self.redirect_to_next(request, logger) + login_form_hidden = settings.LOGIN_FORM_HIDDEN return render(request, self.template_name, { 'form': form, 'auth_backends': self.get_auth_backends(request), + 'login_form_hidden': login_form_hidden, }) def post(self, request): @@ -123,12 +127,18 @@ class LoginView(View): # Set the user's preferred language (if any) if language := request.user.config.get('locale.language'): - response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age()) + response.set_cookie( + key=settings.LANGUAGE_COOKIE_NAME, + value=language, + max_age=request.session.get_expiry_age(), + secure=settings.SESSION_COOKIE_SECURE, + ) return response else: - logger.debug(f"Login form validation failed for username: {form['username'].value()}") + username = form['username'].value() + logger.debug(f"Login form validation failed for username: {remove_linebreaks(username)}") return render(request, self.template_name, { 'form': form, @@ -139,11 +149,11 @@ class LoginView(View): data = request.POST if request.method == "POST" else request.GET redirect_url = data.get('next', settings.LOGIN_REDIRECT_URL) - if redirect_url and url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None): - logger.debug(f"Redirecting user to {redirect_url}") + if redirect_url and safe_for_redirect(redirect_url): + logger.debug(f"Redirecting user to {remove_linebreaks(redirect_url)}") else: if redirect_url: - logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_url}") + logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {remove_linebreaks(redirect_url)}") redirect_url = reverse('home') return HttpResponseRedirect(redirect_url) @@ -218,7 +228,12 @@ class UserConfigView(LoginRequiredMixin, View): # Set/clear language cookie if language := form.cleaned_data['locale.language']: - response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age()) + response.set_cookie( + key=settings.LANGUAGE_COOKIE_NAME, + value=language, + max_age=request.session.get_expiry_age(), + secure=settings.SESSION_COOKIE_SECURE, + ) else: response.delete_cookie(settings.LANGUAGE_COOKIE_NAME) diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index 964f69f83..7775255fc 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -95,7 +95,7 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): ) -class ProviderAccountFilterSet(NetBoxModelFilterSet): +class ProviderAccountFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): provider_id = django_filters.ModelMultipleChoiceFilter( queryset=Provider.objects.all(), label=_('Provider (ID)'), @@ -234,6 +234,11 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte to_field_name='slug', label=_('Site (slug)'), ) + location_id = django_filters.ModelMultipleChoiceFilter( + field_name='terminations___location', + label=_('Location (ID)'), + queryset=Location.objects.all(), + ) termination_a_id = django_filters.ModelMultipleChoiceFilter( queryset=CircuitTermination.objects.all(), label=_('Termination A (ID)'), diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index aefc62655..9b2129989 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -66,11 +66,12 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): tag = TagFilterField(model) -class ProviderAccountFilterForm(NetBoxModelFilterSetForm): +class ProviderAccountFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = ProviderAccount fieldsets = ( FieldSet('q', 'filter_id', 'tag'), FieldSet('provider_id', 'account', name=_('Attributes')), + FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), ) provider_id = DynamicModelMultipleChoiceField( queryset=Provider.objects.all(), @@ -126,7 +127,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi 'type_id', 'status', 'install_date', 'termination_date', 'commit_rate', 'distance', 'distance_unit', name=_('Attributes') ), - FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), ) @@ -181,6 +182,11 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi }, label=_('Site') ) + location_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), + required=False, + label=_('Location') + ) install_date = forms.DateField( label=_('Install date'), required=False, @@ -322,7 +328,7 @@ class VirtualCircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBox fieldsets = ( FieldSet('q', 'filter_id', 'tag'), FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')), - FieldSet('type', 'status', name=_('Attributes')), + FieldSet('type_id', 'status', name=_('Attributes')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) selector_fields = ('filter_id', 'q', 'provider_id', 'provider_network_id') diff --git a/netbox/circuits/graphql/enums.py b/netbox/circuits/graphql/enums.py new file mode 100644 index 000000000..c99fbda95 --- /dev/null +++ b/netbox/circuits/graphql/enums.py @@ -0,0 +1,16 @@ +import strawberry + +from circuits.choices import * + +__all__ = ( + 'CircuitStatusEnum', + 'CircuitTerminationSideEnum', + 'CircuitPriorityEnum', + 'VirtualCircuitTerminationRoleEnum', +) + + +CircuitPriorityEnum = strawberry.enum(CircuitPriorityChoices.as_enum(prefix='priority')) +CircuitStatusEnum = strawberry.enum(CircuitStatusChoices.as_enum('status')) +CircuitTerminationSideEnum = strawberry.enum(CircuitTerminationSideChoices.as_enum(prefix='side')) +VirtualCircuitTerminationRoleEnum = strawberry.enum(VirtualCircuitTerminationRoleChoices.as_enum(prefix='role')) diff --git a/netbox/circuits/graphql/filter_mixins.py b/netbox/circuits/graphql/filter_mixins.py new file mode 100644 index 000000000..3ae6fa82e --- /dev/null +++ b/netbox/circuits/graphql/filter_mixins.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django + +from netbox.graphql.filter_mixins import OrganizationalModelFilterMixin + +if TYPE_CHECKING: + from netbox.graphql.enums import ColorEnum + +__all__ = ( + 'BaseCircuitTypeFilterMixin', +) + + +@dataclass +class BaseCircuitTypeFilterMixin(OrganizationalModelFilterMixin): + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() diff --git a/netbox/circuits/graphql/filters.py b/netbox/circuits/graphql/filters.py index 7d066f428..966849fd0 100644 --- a/netbox/circuits/graphql/filters.py +++ b/netbox/circuits/graphql/filters.py @@ -1,7 +1,30 @@ -import strawberry_django +from datetime import date +from typing import Annotated, TYPE_CHECKING -from circuits import filtersets, models -from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin +import strawberry +import strawberry_django +from strawberry.scalars import ID +from strawberry_django import FilterLookup, DateFilterLookup + +from circuits import models +from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin +from dcim.graphql.filter_mixins import CabledObjectModelFilterMixin +from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin +from netbox.graphql.filter_mixins import ( + DistanceFilterMixin, + ImageAttachmentFilterMixin, + OrganizationalModelFilterMixin, + PrimaryModelFilterMixin, +) +from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin +from .filter_mixins import BaseCircuitTypeFilterMixin + +if TYPE_CHECKING: + from core.graphql.filters import ContentTypeFilter + from dcim.graphql.filters import InterfaceFilter, LocationFilter, RegionFilter, SiteFilter, SiteGroupFilter + from ipam.graphql.filters import ASNFilter + from netbox.graphql.filter_lookups import IntegerLookup + from .enums import * __all__ = ( 'CircuitFilter', @@ -19,66 +42,183 @@ __all__ = ( @strawberry_django.filter(models.CircuitTermination, lookups=True) -@autotype_decorator(filtersets.CircuitTerminationFilterSet) -class CircuitTerminationFilter(BaseFilterMixin): - pass +class CircuitTerminationFilter( + BaseObjectTypeFilterMixin, + CustomFieldsFilterMixin, + TagsFilterMixin, + ChangeLogFilterMixin, + CabledObjectModelFilterMixin, +): + circuit: Annotated['CircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + term_side: Annotated['CircuitTerminationSideEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + termination_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + termination_id: ID | None = strawberry_django.filter_field() + port_speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + upstream_speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + xconnect_id: FilterLookup[str] | None = strawberry_django.filter_field() + pp_info: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + + # Cached relations + _provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field(name='provider_network') + ) + _location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field(name='location') + ) + _region: Annotated['RegionFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field(name='region') + ) + _site_group: Annotated['SiteGroupFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field(name='site_group') + ) + _site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field(name='site') + ) @strawberry_django.filter(models.Circuit, lookups=True) -@autotype_decorator(filtersets.CircuitFilterSet) -class CircuitFilter(BaseFilterMixin): - pass +class CircuitFilter( + ContactFilterMixin, + ImageAttachmentFilterMixin, + DistanceFilterMixin, + TenancyFilterMixin, + PrimaryModelFilterMixin +): + cid: FilterLookup[str] | None = strawberry_django.filter_field() + provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + provider_id: ID | None = strawberry_django.filter_field() + provider_account: Annotated['ProviderAccountFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + provider_account_id: ID | None = strawberry_django.filter_field() + type: Annotated['CircuitTypeFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + type_id: ID | None = strawberry_django.filter_field() + status: Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + install_date: DateFilterLookup[date] | None = strawberry_django.filter_field() + termination_date: DateFilterLookup[date] | None = strawberry_django.filter_field() + commit_rate: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + terminations: Annotated['CircuitTerminationFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.CircuitType, lookups=True) -@autotype_decorator(filtersets.CircuitTypeFilterSet) -class CircuitTypeFilter(BaseFilterMixin): +class CircuitTypeFilter(BaseCircuitTypeFilterMixin): pass @strawberry_django.filter(models.CircuitGroup, lookups=True) -@autotype_decorator(filtersets.CircuitGroupFilterSet) -class CircuitGroupFilter(BaseFilterMixin): +class CircuitGroupFilter(TenancyFilterMixin, OrganizationalModelFilterMixin): pass @strawberry_django.filter(models.CircuitGroupAssignment, lookups=True) -@autotype_decorator(filtersets.CircuitGroupAssignmentFilterSet) -class CircuitGroupAssignmentFilter(BaseFilterMixin): - pass +class CircuitGroupAssignmentFilter( + BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin +): + member_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + member_id: ID | None = strawberry_django.filter_field() + group: Annotated['CircuitGroupFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + group_id: ID | None = strawberry_django.filter_field() + priority: Annotated['CircuitPriorityEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.Provider, lookups=True) -@autotype_decorator(filtersets.ProviderFilterSet) -class ProviderFilter(BaseFilterMixin): - pass +class ProviderFilter(ContactFilterMixin, PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() + asns: Annotated['ASNFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() + circuits: Annotated['CircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.ProviderAccount, lookups=True) -@autotype_decorator(filtersets.ProviderAccountFilterSet) -class ProviderAccountFilter(BaseFilterMixin): - pass +class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilterMixin): + provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + provider_id: ID | None = strawberry_django.filter_field() + account: FilterLookup[str] | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.ProviderNetwork, lookups=True) -@autotype_decorator(filtersets.ProviderNetworkFilterSet) -class ProviderNetworkFilter(BaseFilterMixin): - pass +class ProviderNetworkFilter(PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + provider_id: ID | None = strawberry_django.filter_field() + service_id: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.VirtualCircuitType, lookups=True) -@autotype_decorator(filtersets.VirtualCircuitTypeFilterSet) -class VirtualCircuitTypeFilter(BaseFilterMixin): +class VirtualCircuitTypeFilter(BaseCircuitTypeFilterMixin): pass @strawberry_django.filter(models.VirtualCircuit, lookups=True) -@autotype_decorator(filtersets.VirtualCircuitFilterSet) -class VirtualCircuitFilter(BaseFilterMixin): - pass +class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilterMixin): + cid: FilterLookup[str] | None = strawberry_django.filter_field() + provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + provider_network_id: ID | None = strawberry_django.filter_field() + provider_account: Annotated['ProviderAccountFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + provider_account_id: ID | None = strawberry_django.filter_field() + type: Annotated['VirtualCircuitTypeFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + type_id: ID | None = strawberry_django.filter_field() + status: Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + group_assignments: Annotated['CircuitGroupAssignmentFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.VirtualCircuitTermination, lookups=True) -@autotype_decorator(filtersets.VirtualCircuitTerminationFilterSet) -class VirtualCircuitTerminationFilter(BaseFilterMixin): - pass +class VirtualCircuitTerminationFilter( + BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin +): + virtual_circuit: Annotated['VirtualCircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + virtual_circuit_id: ID | None = strawberry_django.filter_field() + role: Annotated['VirtualCircuitTerminationRoleEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + interface: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + interface_id: ID | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/circuits/graphql/types.py b/netbox/circuits/graphql/types.py index 564b5ed6f..89d2a33b6 100644 --- a/netbox/circuits/graphql/types.py +++ b/netbox/circuits/graphql/types.py @@ -1,4 +1,4 @@ -from typing import Annotated, List, Union +from typing import Annotated, List, TYPE_CHECKING, Union import strawberry import strawberry_django @@ -10,11 +10,15 @@ from netbox.graphql.types import BaseObjectType, NetBoxObjectType, ObjectType, O from tenancy.graphql.types import TenantType from .filters import * +if TYPE_CHECKING: + from dcim.graphql.types import InterfaceType, LocationType, RegionType, SiteGroupType, SiteType + from ipam.graphql.types import ASNType + __all__ = ( - 'CircuitTerminationType', - 'CircuitType', 'CircuitGroupAssignmentType', 'CircuitGroupType', + 'CircuitTerminationType', + 'CircuitType', 'CircuitTypeType', 'ProviderType', 'ProviderAccountType', @@ -28,7 +32,8 @@ __all__ = ( @strawberry_django.type( models.Provider, fields='__all__', - filters=ProviderFilter + filters=ProviderFilter, + pagination=True ) class ProviderType(NetBoxObjectType, ContactsMixin): @@ -41,9 +46,10 @@ class ProviderType(NetBoxObjectType, ContactsMixin): @strawberry_django.type( models.ProviderAccount, fields='__all__', - filters=ProviderAccountFilter + filters=ProviderAccountFilter, + pagination=True ) -class ProviderAccountType(NetBoxObjectType): +class ProviderAccountType(ContactsMixin, NetBoxObjectType): provider: Annotated["ProviderType", strawberry.lazy('circuits.graphql.types')] circuits: List[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]] @@ -52,7 +58,8 @@ class ProviderAccountType(NetBoxObjectType): @strawberry_django.type( models.ProviderNetwork, fields='__all__', - filters=ProviderNetworkFilter + filters=ProviderNetworkFilter, + pagination=True ) class ProviderNetworkType(NetBoxObjectType): provider: Annotated["ProviderType", strawberry.lazy('circuits.graphql.types')] @@ -62,8 +69,9 @@ class ProviderNetworkType(NetBoxObjectType): @strawberry_django.type( models.CircuitTermination, - exclude=('termination_type', 'termination_id', '_location', '_region', '_site', '_site_group', '_provider_network'), - filters=CircuitTerminationFilter + exclude=['termination_type', 'termination_id', '_location', '_region', '_site', '_site_group', '_provider_network'], + filters=CircuitTerminationFilter, + pagination=True ) class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType): circuit: Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')] @@ -82,7 +90,8 @@ class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, Ob @strawberry_django.type( models.CircuitType, fields='__all__', - filters=CircuitTypeFilter + filters=CircuitTypeFilter, + pagination=True ) class CircuitTypeType(OrganizationalObjectType): color: str @@ -93,7 +102,8 @@ class CircuitTypeType(OrganizationalObjectType): @strawberry_django.type( models.Circuit, fields='__all__', - filters=CircuitFilter + filters=CircuitFilter, + pagination=True ) class CircuitType(NetBoxObjectType, ContactsMixin): provider: ProviderType @@ -109,7 +119,8 @@ class CircuitType(NetBoxObjectType, ContactsMixin): @strawberry_django.type( models.CircuitGroup, fields='__all__', - filters=CircuitGroupFilter + filters=CircuitGroupFilter, + pagination=True ) class CircuitGroupType(OrganizationalObjectType): tenant: TenantType | None @@ -117,8 +128,9 @@ class CircuitGroupType(OrganizationalObjectType): @strawberry_django.type( models.CircuitGroupAssignment, - exclude=('member_type', 'member_id'), - filters=CircuitGroupAssignmentFilter + exclude=['member_type', 'member_id'], + filters=CircuitGroupAssignmentFilter, + pagination=True ) class CircuitGroupAssignmentType(TagsMixin, BaseObjectType): group: Annotated["CircuitGroupType", strawberry.lazy('circuits.graphql.types')] @@ -134,7 +146,8 @@ class CircuitGroupAssignmentType(TagsMixin, BaseObjectType): @strawberry_django.type( models.VirtualCircuitType, fields='__all__', - filters=VirtualCircuitTypeFilter + filters=VirtualCircuitTypeFilter, + pagination=True ) class VirtualCircuitTypeType(OrganizationalObjectType): color: str @@ -145,7 +158,8 @@ class VirtualCircuitTypeType(OrganizationalObjectType): @strawberry_django.type( models.VirtualCircuitTermination, fields='__all__', - filters=VirtualCircuitTerminationFilter + filters=VirtualCircuitTerminationFilter, + pagination=True ) class VirtualCircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType): virtual_circuit: Annotated[ @@ -161,7 +175,8 @@ class VirtualCircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType): @strawberry_django.type( models.VirtualCircuit, fields='__all__', - filters=VirtualCircuitFilter + filters=VirtualCircuitFilter, + pagination=True ) class VirtualCircuitType(NetBoxObjectType): provider_network: ProviderNetworkType = strawberry_django.field(select_related=["provider_network"]) diff --git a/netbox/circuits/migrations/0002_squashed_0029.py b/netbox/circuits/migrations/0002_squashed_0029.py index cb61d8feb..0062575cd 100644 --- a/netbox/circuits/migrations/0002_squashed_0029.py +++ b/netbox/circuits/migrations/0002_squashed_0029.py @@ -5,11 +5,11 @@ import taggit.managers class Migration(migrations.Migration): dependencies = [ - ('dcim', '0001_initial'), + ('dcim', '0001_squashed'), ('contenttypes', '0002_remove_content_type_name'), - ('circuits', '0001_initial'), - ('extras', '0001_initial'), - ('tenancy', '0001_initial'), + ('circuits', '0001_squashed'), + ('extras', '0001_squashed'), + ('tenancy', '0001_squashed_0012'), ] replaces = [ diff --git a/netbox/circuits/migrations/0038_squashed_0042.py b/netbox/circuits/migrations/0038_squashed_0042.py index fa944b763..be07638b4 100644 --- a/netbox/circuits/migrations/0038_squashed_0042.py +++ b/netbox/circuits/migrations/0038_squashed_0042.py @@ -15,8 +15,8 @@ class Migration(migrations.Migration): ] dependencies = [ - ('circuits', '0037_new_cabling_models'), - ('dcim', '0160_populate_cable_ends'), + ('circuits', '0003_squashed_0037'), + ('dcim', '0160_squashed_0166'), ] operations = [ diff --git a/netbox/circuits/migrations/0043_circuittype_color.py b/netbox/circuits/migrations/0043_circuittype_color.py index 6c4dffeb6..400c419ef 100644 --- a/netbox/circuits/migrations/0043_circuittype_color.py +++ b/netbox/circuits/migrations/0043_circuittype_color.py @@ -6,7 +6,7 @@ import utilities.fields class Migration(migrations.Migration): dependencies = [ - ('circuits', '0042_provideraccount'), + ('circuits', '0038_squashed_0042'), ] operations = [ diff --git a/netbox/circuits/migrations/0047_circuittermination__termination.py b/netbox/circuits/migrations/0047_circuittermination__termination.py index f78e17ec3..4caa3a37d 100644 --- a/netbox/circuits/migrations/0047_circuittermination__termination.py +++ b/netbox/circuits/migrations/0047_circuittermination__termination.py @@ -39,9 +39,6 @@ class Migration(migrations.Migration): name='termination_type', field=models.ForeignKey( blank=True, - limit_choices_to=models.Q( - ('model__in', ('region', 'sitegroup', 'site', 'location', 'providernetwork')) - ), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', diff --git a/netbox/circuits/migrations/0048_circuitterminations_cached_relations.py b/netbox/circuits/migrations/0048_circuitterminations_cached_relations.py index fc1cef0e5..9be254d54 100644 --- a/netbox/circuits/migrations/0048_circuitterminations_cached_relations.py +++ b/netbox/circuits/migrations/0048_circuitterminations_cached_relations.py @@ -1,4 +1,3 @@ -# Generated by Django 5.0.9 on 2024-10-21 17:34 import django.db.models.deletion from django.db import migrations, models @@ -16,7 +15,7 @@ def populate_denormalized_fields(apps, schema_editor): termination._site_id = termination.site_id # Note: Location cannot be set prior to migration - CircuitTermination.objects.bulk_update(terminations, ['_region', '_site_group', '_site']) + CircuitTermination.objects.bulk_update(terminations, ['_region', '_site_group', '_site'], batch_size=100) class Migration(migrations.Migration): diff --git a/netbox/circuits/migrations/0051_virtualcircuit_group_assignment.py b/netbox/circuits/migrations/0051_virtualcircuit_group_assignment.py index f8c0fd653..0418c26e5 100644 --- a/netbox/circuits/migrations/0051_virtualcircuit_group_assignment.py +++ b/netbox/circuits/migrations/0051_virtualcircuit_group_assignment.py @@ -51,7 +51,6 @@ class Migration(migrations.Migration): name='member_type', field=models.ForeignKey( on_delete=django.db.models.deletion.PROTECT, - limit_choices_to=models.Q(('app_label', 'circuits'), ('model__in', ['circuit', 'virtualcircuit'])), related_name='+', to='contenttypes.contenttype', blank=True, @@ -68,7 +67,6 @@ class Migration(migrations.Migration): model_name='circuitgroupassignment', name='member_type', field=models.ForeignKey( - limit_choices_to=models.Q(('app_label', 'circuits'), ('model__in', ['circuit', 'virtualcircuit'])), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype' diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 9c7714153..65f6323ae 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -182,7 +182,6 @@ class CircuitGroupAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, """ member_type = models.ForeignKey( to='contenttypes.ContentType', - limit_choices_to=CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS, on_delete=models.PROTECT, related_name='+' ) @@ -249,7 +248,6 @@ class CircuitTermination( termination_type = models.ForeignKey( to='contenttypes.ContentType', on_delete=models.PROTECT, - limit_choices_to=Q(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES), related_name='+', blank=True, null=True @@ -349,9 +347,8 @@ class CircuitTermination( def clean(self): super().clean() - # Must define either site *or* provider network if self.termination is None: - raise ValidationError(_("A circuit termination must attach to termination.")) + raise ValidationError(_("A circuit termination must attach to a terminating object.")) def save(self, *args, **kwargs): # Cache objects associated with the terminating object (for filtering) diff --git a/netbox/circuits/search.py b/netbox/circuits/search.py index 2ea11b7fd..f7654e328 100644 --- a/netbox/circuits/search.py +++ b/netbox/circuits/search.py @@ -34,7 +34,7 @@ class CircuitTerminationIndex(SearchIndex): ('port_speed', 2000), ('upstream_speed', 2000), ) - display_attrs = ('circuit', 'site', 'provider_network', 'description') + display_attrs = ('circuit', 'termination', 'description') @register_search @@ -90,7 +90,7 @@ class VirtualCircuitIndex(SearchIndex): ('description', 500), ('comments', 5000), ) - display_attrs = ('provider', 'provider_network', 'provider_account', 'status', 'tenant', 'description') + display_attrs = ('provider_network', 'provider_account', 'status', 'tenant', 'description') @register_search diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py index 9e59ec019..3643446bd 100644 --- a/netbox/circuits/tables/circuits.py +++ b/netbox/circuits/tables/circuits.py @@ -61,9 +61,8 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): linkify=True, verbose_name=_('Account') ) - type = tables.Column( + type = columns.ColoredLabelColumn( verbose_name=_('Type'), - linkify=True ) status = columns.ChoiceFieldColumn() termination_a = columns.TemplateColumn( @@ -111,7 +110,7 @@ class CircuitTerminationTable(NetBoxTable): provider = tables.Column( verbose_name=_('Provider'), linkify=True, - accessor='circuit.provider' + accessor='circuit__provider' ) term_side = tables.Column( verbose_name=_('Side') diff --git a/netbox/circuits/tables/providers.py b/netbox/circuits/tables/providers.py index d70c77e9c..54a5c2cc9 100644 --- a/netbox/circuits/tables/providers.py +++ b/netbox/circuits/tables/providers.py @@ -23,7 +23,6 @@ class ProviderTable(ContactsColumnMixin, NetBoxTable): verbose_name=_('Accounts') ) account_count = columns.LinkedCountColumn( - accessor=tables.A('accounts__count'), viewname='circuits:provideraccount_list', url_params={'provider_id': 'pk'}, verbose_name=_('Account Count') @@ -33,7 +32,6 @@ class ProviderTable(ContactsColumnMixin, NetBoxTable): verbose_name=_('ASNs') ) asn_count = columns.LinkedCountColumn( - accessor=tables.A('asns__count'), viewname='ipam:asn_list', url_params={'provider_id': 'pk'}, verbose_name=_('ASN Count') diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index b32abd34e..91077ee64 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -3,8 +3,10 @@ from django.test import TestCase from circuits.choices import * from circuits.filtersets import * from circuits.models import * -from dcim.choices import InterfaceTypeChoices -from dcim.models import Cable, Device, DeviceRole, DeviceType, Interface, Manufacturer, Region, Site, SiteGroup +from dcim.choices import InterfaceTypeChoices, LocationStatusChoices +from dcim.models import ( + Cable, Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Region, Site, SiteGroup +) from ipam.models import ASN, RIR from netbox.choices import DistanceUnitChoices from tenancy.models import Tenant, TenantGroup @@ -225,6 +227,17 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests): ) ProviderNetwork.objects.bulk_create(provider_networks) + locations = ( + Location.objects.create( + site=sites[0], name='Test Location 1', slug='test-location-1', + status=LocationStatusChoices.STATUS_ACTIVE, + ), + Location.objects.create( + site=sites[1], name='Test Location 2', slug='test-location-2', + status=LocationStatusChoices.STATUS_ACTIVE, + ), + ) + circuits = ( Circuit( provider=providers[0], @@ -305,7 +318,9 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests): circuit_terminations = (( CircuitTermination(circuit=circuits[0], termination=sites[0], term_side='A'), + CircuitTermination(circuit=circuits[0], termination=locations[0], term_side='Z'), CircuitTermination(circuit=circuits[1], termination=sites[1], term_side='A'), + CircuitTermination(circuit=circuits[1], termination=locations[1], term_side='Z'), CircuitTermination(circuit=circuits[2], termination=sites[2], term_side='A'), CircuitTermination(circuit=circuits[3], termination=provider_networks[0], term_side='A'), CircuitTermination(circuit=circuits[4], termination=provider_networks[1], term_side='A'), @@ -395,6 +410,11 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'site': [sites[0].slug, sites[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_location(self): + location_ids = Location.objects.values_list('id', flat=True)[:2] + params = {'location_id': location_ids} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_tenant(self): tenants = Tenant.objects.all()[:2] params = {'tenant_id': [tenants[0].pk, tenants[1].pk]} diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 3bd81c33a..62056cfbe 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -4,8 +4,8 @@ from django.shortcuts import get_object_or_404, redirect, render from django.utils.translation import gettext_lazy as _ from dcim.views import PathTraceView +from ipam.models import ASN from netbox.views import generic -from tenancy.views import ObjectContactsView from utilities.forms import ConfirmationForm from utilities.query import count_related from utilities.views import GetRelatedModelsMixin, register_model_view @@ -20,7 +20,9 @@ from .models import * @register_model_view(Provider, 'list', path='', detail=False) class ProviderListView(generic.ObjectListView): queryset = Provider.objects.annotate( - count_circuits=count_related(Circuit, 'provider') + count_circuits=count_related(Circuit, 'provider'), + asn_count=count_related(ASN, 'providers'), + account_count=count_related(ProviderAccount, 'provider'), ) filterset = filtersets.ProviderFilterSet filterset_form = forms.ProviderFilterForm @@ -33,7 +35,19 @@ class ProviderView(GetRelatedModelsMixin, generic.ObjectView): def get_extra_context(self, request, instance): return { - 'related_models': self.get_related_models(request, instance), + 'related_models': self.get_related_models( + request, + instance, + omit=(), + extra=( + ( + VirtualCircuit.objects.restrict(request.user, 'view').filter( + provider_network__provider=instance + ), + 'provider_id', + ), + ), + ), } @@ -49,7 +63,7 @@ class ProviderDeleteView(generic.ObjectDeleteView): queryset = Provider.objects.all() -@register_model_view(Provider, 'bulk_import', detail=False) +@register_model_view(Provider, 'bulk_import', path='import', detail=False) class ProviderBulkImportView(generic.BulkImportView): queryset = Provider.objects.all() model_form = forms.ProviderImportForm @@ -74,11 +88,6 @@ class ProviderBulkDeleteView(generic.BulkDeleteView): table = tables.ProviderTable -@register_model_view(Provider, 'contacts') -class ProviderContactsView(ObjectContactsView): - queryset = Provider.objects.all() - - # # ProviderAccounts # @@ -115,7 +124,7 @@ class ProviderAccountDeleteView(generic.ObjectDeleteView): queryset = ProviderAccount.objects.all() -@register_model_view(ProviderAccount, 'bulk_import', detail=False) +@register_model_view(ProviderAccount, 'bulk_import', path='import', detail=False) class ProviderAccountBulkImportView(generic.BulkImportView): queryset = ProviderAccount.objects.all() model_form = forms.ProviderAccountImportForm @@ -141,11 +150,6 @@ class ProviderAccountBulkDeleteView(generic.BulkDeleteView): table = tables.ProviderAccountTable -@register_model_view(ProviderAccount, 'contacts') -class ProviderAccountContactsView(ObjectContactsView): - queryset = ProviderAccount.objects.all() - - # # Provider networks # @@ -167,11 +171,16 @@ class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView): 'related_models': self.get_related_models( request, instance, + omit=(CircuitTermination,), extra=( ( Circuit.objects.restrict(request.user, 'view').filter(terminations___provider_network=instance), 'provider_network_id', ), + ( + CircuitTermination.objects.restrict(request.user, 'view').filter(_provider_network=instance), + 'provider_network_id', + ), ), ), } @@ -189,7 +198,7 @@ class ProviderNetworkDeleteView(generic.ObjectDeleteView): queryset = ProviderNetwork.objects.all() -@register_model_view(ProviderNetwork, 'bulk_import', detail=False) +@register_model_view(ProviderNetwork, 'bulk_import', path='import', detail=False) class ProviderNetworkBulkImportView(generic.BulkImportView): queryset = ProviderNetwork.objects.all() model_form = forms.ProviderNetworkImportForm @@ -246,7 +255,7 @@ class CircuitTypeDeleteView(generic.ObjectDeleteView): queryset = CircuitType.objects.all() -@register_model_view(CircuitType, 'bulk_import', detail=False) +@register_model_view(CircuitType, 'bulk_import', path='import', detail=False) class CircuitTypeBulkImportView(generic.BulkImportView): queryset = CircuitType.objects.all() model_form = forms.CircuitTypeImportForm @@ -302,7 +311,7 @@ class CircuitDeleteView(generic.ObjectDeleteView): queryset = Circuit.objects.all() -@register_model_view(Circuit, 'bulk_import', detail=False) +@register_model_view(Circuit, 'bulk_import', path='import', detail=False) class CircuitBulkImportView(generic.BulkImportView): queryset = Circuit.objects.all() model_form = forms.CircuitImportForm @@ -413,11 +422,6 @@ class CircuitSwapTerminations(generic.ObjectEditView): }) -@register_model_view(Circuit, 'contacts') -class CircuitContactsView(ObjectContactsView): - queryset = Circuit.objects.all() - - # # Circuit terminations # @@ -447,7 +451,7 @@ class CircuitTerminationDeleteView(generic.ObjectDeleteView): queryset = CircuitTermination.objects.all() -@register_model_view(CircuitTermination, 'bulk_import', detail=False) +@register_model_view(CircuitTermination, 'bulk_import', path='import', detail=False) class CircuitTerminationBulkImportView(generic.BulkImportView): queryset = CircuitTermination.objects.all() model_form = forms.CircuitTerminationImportForm @@ -508,7 +512,7 @@ class CircuitGroupDeleteView(generic.ObjectDeleteView): queryset = CircuitGroup.objects.all() -@register_model_view(CircuitGroup, 'bulk_import', detail=False) +@register_model_view(CircuitGroup, 'bulk_import', path='import', detail=False) class CircuitGroupBulkImportView(generic.BulkImportView): queryset = CircuitGroup.objects.all() model_form = forms.CircuitGroupImportForm @@ -558,7 +562,7 @@ class CircuitGroupAssignmentDeleteView(generic.ObjectDeleteView): queryset = CircuitGroupAssignment.objects.all() -@register_model_view(CircuitGroupAssignment, 'bulk_import', detail=False) +@register_model_view(CircuitGroupAssignment, 'bulk_import', path='import', detail=False) class CircuitGroupAssignmentBulkImportView(generic.BulkImportView): queryset = CircuitGroupAssignment.objects.all() model_form = forms.CircuitGroupAssignmentImportForm @@ -615,7 +619,7 @@ class VirtualCircuitTypeDeleteView(generic.ObjectDeleteView): queryset = VirtualCircuitType.objects.all() -@register_model_view(VirtualCircuitType, 'bulk_import', detail=False) +@register_model_view(VirtualCircuitType, 'bulk_import', path='import', detail=False) class VirtualCircuitTypeBulkImportView(generic.BulkImportView): queryset = VirtualCircuitType.objects.all() model_form = forms.VirtualCircuitTypeImportForm diff --git a/netbox/core/api/schema.py b/netbox/core/api/schema.py index 663ee2899..0c59da5a1 100644 --- a/netbox/core/api/schema.py +++ b/netbox/core/api/schema.py @@ -2,12 +2,13 @@ import re import typing from collections import OrderedDict -from drf_spectacular.extensions import OpenApiSerializerFieldExtension +from drf_spectacular.extensions import OpenApiSerializerFieldExtension, OpenApiSerializerExtension, _SchemaType from drf_spectacular.openapi import AutoSchema from drf_spectacular.plumbing import ( build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc, ) from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import Direction from netbox.api.fields import ChoiceField from netbox.api.serializers import WritableNestedSerializer @@ -277,3 +278,40 @@ class FixSerializedPKRelatedField(OpenApiSerializerFieldExtension): return component.ref if component else None else: return build_basic_type(OpenApiTypes.INT) + + +class FixIntegerRangeSerializerSchema(OpenApiSerializerExtension): + target_class = 'netbox.api.fields.IntegerRangeSerializer' + + def map_serializer(self, auto_schema: 'AutoSchema', direction: Direction) -> _SchemaType: + return { + 'type': 'array', + 'items': { + 'type': 'array', + 'items': { + 'type': 'integer', + }, + 'minItems': 2, + 'maxItems': 2, + }, + } + + +# Nested models can be passed by ID in requests +# The logic for this is handled in `BaseModelSerializer.to_internal_value` +class FixWritableNestedSerializerAllowPK(OpenApiSerializerFieldExtension): + target_class = 'netbox.api.serializers.BaseModelSerializer' + match_subclasses = True + + def map_serializer_field(self, auto_schema, direction): + schema = auto_schema._map_serializer_field(self.target, direction, bypass_extensions=True) + if schema is None: + return schema + if direction == 'request' and self.target.nested: + return { + 'oneOf': [ + build_basic_type(OpenApiTypes.INT), + schema, + ] + } + return schema diff --git a/netbox/core/api/serializers_/data.py b/netbox/core/api/serializers_/data.py index 2c155ba6b..3f2ddb2a0 100644 --- a/netbox/core/api/serializers_/data.py +++ b/netbox/core/api/serializers_/data.py @@ -26,8 +26,8 @@ class DataSourceSerializer(NetBoxModelSerializer): model = DataSource fields = [ 'id', 'url', 'display_url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description', - 'parameters', 'ignore_rules', 'comments', 'custom_fields', 'created', 'last_updated', 'last_synced', - 'file_count', + 'sync_interval', 'parameters', 'ignore_rules', 'comments', 'custom_fields', 'created', 'last_updated', + 'last_synced', 'file_count', ] brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/core/apps.py b/netbox/core/apps.py index 9674860b9..c081fb064 100644 --- a/netbox/core/apps.py +++ b/netbox/core/apps.py @@ -3,7 +3,10 @@ from django.conf import settings from django.core.cache import cache from django.db import models from django.db.migrations.operations import AlterModelOptions +from django.utils.translation import gettext as _ +from core.events import * +from netbox.events import EventType, EVENT_TYPE_KIND_DANGER, EVENT_TYPE_KIND_SUCCESS, EVENT_TYPE_KIND_WARNING from utilities.migration import custom_deconstruct # Ignore verbose_name & verbose_name_plural Meta options when calculating model migrations @@ -19,6 +22,7 @@ class CoreConfig(AppConfig): def ready(self): from core.api import schema # noqa: F401 + from core.checks import check_duplicate_indexes # noqa: F401 from netbox.models.features import register_models from . import data_backends, events, search # noqa: F401 from netbox import context_managers # noqa: F401 @@ -26,6 +30,18 @@ class CoreConfig(AppConfig): # Register models register_models(*self.get_models()) + # Register core events + EventType(OBJECT_CREATED, _('Object created')).register() + EventType(OBJECT_UPDATED, _('Object updated')).register() + EventType(OBJECT_DELETED, _('Object deleted'), destructive=True).register() + EventType(JOB_STARTED, _('Job started')).register() + EventType(JOB_COMPLETED, _('Job completed'), kind=EVENT_TYPE_KIND_SUCCESS).register() + EventType(JOB_FAILED, _('Job failed'), kind=EVENT_TYPE_KIND_WARNING).register() + EventType(JOB_ERRORED, _('Job errored'), kind=EVENT_TYPE_KIND_DANGER).register() + # Clear Redis cache on startup in development mode if settings.DEBUG: - cache.clear() + try: + cache.clear() + except Exception: + pass diff --git a/netbox/core/checks.py b/netbox/core/checks.py new file mode 100644 index 000000000..cab52a025 --- /dev/null +++ b/netbox/core/checks.py @@ -0,0 +1,41 @@ +from django.core.checks import Error, register, Tags +from django.db.models import Index, UniqueConstraint +from django.apps import apps + +__all__ = ( + 'check_duplicate_indexes', +) + + +@register(Tags.models) +def check_duplicate_indexes(app_configs, **kwargs): + """ + Check for an index which is redundant to a declared unique constraint. + """ + errors = [] + + for model in apps.get_models(): + if not (meta := getattr(model, "_meta", None)): + continue + + index_fields = { + tuple(index.fields) for index in getattr(meta, 'indexes', []) + if isinstance(index, Index) + } + constraint_fields = { + tuple(constraint.fields) for constraint in getattr(meta, 'constraints', []) + if isinstance(constraint, UniqueConstraint) + } + + # Find overlapping definitions + if duplicated := index_fields & constraint_fields: + for fields in duplicated: + errors.append( + Error( + f"Model '{model.__name__}' defines the same field set {fields} in both `Meta.indexes` and " + f"`Meta.constraints`.", + obj=model, + ) + ) + + return errors diff --git a/netbox/core/choices.py b/netbox/core/choices.py index 442acc26b..6603a7d4f 100644 --- a/netbox/core/choices.py +++ b/netbox/core/choices.py @@ -81,8 +81,10 @@ class JobIntervalChoices(ChoiceSet): CHOICES = ( (INTERVAL_MINUTELY, _('Minutely')), (INTERVAL_HOURLY, _('Hourly')), + (INTERVAL_HOURLY * 12, _('12 hours')), (INTERVAL_DAILY, _('Daily')), (INTERVAL_WEEKLY, _('Weekly')), + (INTERVAL_DAILY * 30, _('30 days')), ) diff --git a/netbox/core/data_backends.py b/netbox/core/data_backends.py index 770a3b258..9ba1d5dfd 100644 --- a/netbox/core/data_backends.py +++ b/netbox/core/data_backends.py @@ -7,13 +7,13 @@ from pathlib import Path from urllib.parse import urlparse from django import forms -from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.utils.translation import gettext as _ from netbox.data_backends import DataBackend from netbox.utils import register_data_backend from utilities.constants import HTTP_PROXY_SUPPORTED_SCHEMAS, HTTP_PROXY_SUPPORTED_SOCK_SCHEMAS +from utilities.proxy import resolve_proxies from utilities.socks import ProxyPoolManager from .exceptions import SyncError @@ -70,18 +70,18 @@ class GitBackend(DataBackend): # Initialize backend config config = ConfigDict() - self.use_socks = False + self.socks_proxy = None # Apply HTTP proxy (if configured) - if settings.HTTP_PROXIES: - if proxy := settings.HTTP_PROXIES.get(self.url_scheme, None): - if urlparse(proxy).scheme not in HTTP_PROXY_SUPPORTED_SCHEMAS: - raise ImproperlyConfigured(f"Unsupported Git DataSource proxy scheme: {urlparse(proxy).scheme}") + proxies = resolve_proxies(url=self.url, context={'client': self}) or {} + if proxy := proxies.get(self.url_scheme): + if urlparse(proxy).scheme not in HTTP_PROXY_SUPPORTED_SCHEMAS: + raise ImproperlyConfigured(f"Unsupported Git DataSource proxy scheme: {urlparse(proxy).scheme}") - if self.url_scheme in ('http', 'https'): - config.set("http", "proxy", proxy) - if urlparse(proxy).scheme in HTTP_PROXY_SUPPORTED_SOCK_SCHEMAS: - self.use_socks = True + if self.url_scheme in ('http', 'https'): + config.set("http", "proxy", proxy) + if urlparse(proxy).scheme in HTTP_PROXY_SUPPORTED_SOCK_SCHEMAS: + self.socks_proxy = proxy return config @@ -98,8 +98,8 @@ class GitBackend(DataBackend): } # check if using socks for proxy - if so need to use custom pool_manager - if self.use_socks: - clone_args['pool_manager'] = ProxyPoolManager(settings.HTTP_PROXIES.get(self.url_scheme)) + if self.socks_proxy: + clone_args['pool_manager'] = ProxyPoolManager(self.socks_proxy) if self.url_scheme in ('http', 'https'): if self.params.get('username'): @@ -147,7 +147,7 @@ class S3Backend(DataBackend): # Initialize backend config return Boto3Config( - proxies=settings.HTTP_PROXIES, + proxies=resolve_proxies(url=self.url, context={'client': self}), ) @contextmanager diff --git a/netbox/core/events.py b/netbox/core/events.py index 384b61fd4..06af75df7 100644 --- a/netbox/core/events.py +++ b/netbox/core/events.py @@ -1,7 +1,3 @@ -from django.utils.translation import gettext as _ - -from netbox.events import EventType, EVENT_TYPE_KIND_DANGER, EVENT_TYPE_KIND_SUCCESS, EVENT_TYPE_KIND_WARNING - __all__ = ( 'JOB_COMPLETED', 'JOB_ERRORED', @@ -22,12 +18,3 @@ JOB_STARTED = 'job_started' JOB_COMPLETED = 'job_completed' JOB_FAILED = 'job_failed' JOB_ERRORED = 'job_errored' - -# Register core events -EventType(OBJECT_CREATED, _('Object created')).register() -EventType(OBJECT_UPDATED, _('Object updated')).register() -EventType(OBJECT_DELETED, _('Object deleted'), destructive=True).register() -EventType(JOB_STARTED, _('Job started')).register() -EventType(JOB_COMPLETED, _('Job completed'), kind=EVENT_TYPE_KIND_SUCCESS).register() -EventType(JOB_FAILED, _('Job failed'), kind=EVENT_TYPE_KIND_WARNING).register() -EventType(JOB_ERRORED, _('Job errored'), kind=EVENT_TYPE_KIND_DANGER).register() diff --git a/netbox/core/exceptions.py b/netbox/core/exceptions.py index 8412b0378..5790704c2 100644 --- a/netbox/core/exceptions.py +++ b/netbox/core/exceptions.py @@ -1,2 +1,9 @@ +from django.core.exceptions import ImproperlyConfigured + + class SyncError(Exception): pass + + +class IncompatiblePluginError(ImproperlyConfigured): + pass diff --git a/netbox/core/filtersets.py b/netbox/core/filtersets.py index 21fdaa4ab..42ec22350 100644 --- a/netbox/core/filtersets.py +++ b/netbox/core/filtersets.py @@ -29,6 +29,10 @@ class DataSourceFilterSet(NetBoxModelFilterSet): choices=DataSourceStatusChoices, null_value=None ) + sync_interval = django_filters.MultipleChoiceFilter( + choices=JobIntervalChoices, + null_value=None + ) class Meta: model = DataSource diff --git a/netbox/core/forms/bulk_edit.py b/netbox/core/forms/bulk_edit.py index c1f1fca4d..73618826d 100644 --- a/netbox/core/forms/bulk_edit.py +++ b/netbox/core/forms/bulk_edit.py @@ -1,6 +1,7 @@ from django import forms from django.utils.translation import gettext_lazy as _ +from core.choices import JobIntervalChoices from core.models import * from netbox.forms import NetBoxModelBulkEditForm from netbox.utils import get_data_backend_choices @@ -29,6 +30,11 @@ class DataSourceBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + sync_interval = forms.ChoiceField( + choices=JobIntervalChoices, + required=False, + label=_('Sync interval') + ) comments = CommentField() parameters = forms.JSONField( label=_('Parameters'), @@ -42,8 +48,8 @@ class DataSourceBulkEditForm(NetBoxModelBulkEditForm): model = DataSource fieldsets = ( - FieldSet('type', 'enabled', 'description', 'comments', 'parameters', 'ignore_rules'), + FieldSet('type', 'enabled', 'description', 'sync_interval', 'parameters', 'ignore_rules', 'comments'), ) nullable_fields = ( - 'description', 'description', 'parameters', 'comments', 'parameters', 'ignore_rules', + 'description', 'description', 'sync_interval', 'parameters', 'parameters', 'ignore_rules' 'comments', ) diff --git a/netbox/core/forms/bulk_import.py b/netbox/core/forms/bulk_import.py index 78a859dcb..a5791c945 100644 --- a/netbox/core/forms/bulk_import.py +++ b/netbox/core/forms/bulk_import.py @@ -11,5 +11,6 @@ class DataSourceImportForm(NetBoxModelImportForm): class Meta: model = DataSource fields = ( - 'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'parameters', 'ignore_rules', + 'name', 'type', 'source_url', 'enabled', 'description', 'sync_interval', 'parameters', 'ignore_rules', + 'comments', ) diff --git a/netbox/core/forms/filtersets.py b/netbox/core/forms/filtersets.py index ab4b869b7..0f25932e0 100644 --- a/netbox/core/forms/filtersets.py +++ b/netbox/core/forms/filtersets.py @@ -27,7 +27,7 @@ class DataSourceFilterForm(NetBoxModelFilterSetForm): model = DataSource fieldsets = ( FieldSet('q', 'filter_id'), - FieldSet('type', 'status', name=_('Data Source')), + FieldSet('type', 'status', 'enabled', 'sync_interval', name=_('Data Source')), ) type = forms.MultipleChoiceField( label=_('Type'), @@ -46,6 +46,11 @@ class DataSourceFilterForm(NetBoxModelFilterSetForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + sync_interval = forms.ChoiceField( + label=_('Sync interval'), + choices=JobIntervalChoices, + required=False + ) class DataFileFilterForm(NetBoxModelFilterSetForm): @@ -62,6 +67,7 @@ class DataFileFilterForm(NetBoxModelFilterSetForm): class JobFilterForm(SavedFiltersMixin, FilterForm): + model = Job fieldsets = ( FieldSet('q', 'filter_id'), FieldSet('object_type', 'status', name=_('Attributes')), @@ -162,6 +168,7 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm): class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm): + model = ConfigRevision fieldsets = ( FieldSet('q', 'filter_id'), ) diff --git a/netbox/core/forms/model_forms.py b/netbox/core/forms/model_forms.py index a05377597..0a683a381 100644 --- a/netbox/core/forms/model_forms.py +++ b/netbox/core/forms/model_forms.py @@ -36,7 +36,7 @@ class DataSourceForm(NetBoxModelForm): class Meta: model = DataSource fields = [ - 'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'ignore_rules', 'tags', + 'name', 'type', 'source_url', 'enabled', 'description', 'sync_interval', 'ignore_rules', 'comments', 'tags', ] widgets = { 'ignore_rules': forms.Textarea( @@ -51,7 +51,10 @@ class DataSourceForm(NetBoxModelForm): @property def fieldsets(self): fieldsets = [ - FieldSet('name', 'type', 'source_url', 'enabled', 'description', 'tags', 'ignore_rules', name=_('Source')), + FieldSet( + 'name', 'type', 'source_url', 'description', 'tags', 'ignore_rules', name=_('Source') + ), + FieldSet('enabled', 'sync_interval', name=_('Sync')), ] if self.backend_fields: fieldsets.append( diff --git a/netbox/core/graphql/filter_mixins.py b/netbox/core/graphql/filter_mixins.py new file mode 100644 index 000000000..670ec2ebb --- /dev/null +++ b/netbox/core/graphql/filter_mixins.py @@ -0,0 +1,36 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django +from strawberry import ID +from strawberry_django import DatetimeFilterLookup + +if TYPE_CHECKING: + from .filters import * + +__all__ = ( + 'BaseFilterMixin', + 'BaseObjectTypeFilterMixin', + 'ChangeLogFilterMixin', +) + + +# @strawberry.input +class BaseFilterMixin: ... + + +@dataclass +class BaseObjectTypeFilterMixin(BaseFilterMixin): + id: ID | None = strawberry.UNSET + + +@dataclass +class ChangeLogFilterMixin(BaseFilterMixin): + id: ID | None = strawberry.UNSET + changelog: Annotated['ObjectChangeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() + last_updated: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() diff --git a/netbox/core/graphql/filters.py b/netbox/core/graphql/filters.py index 82da685a5..e5d44674a 100644 --- a/netbox/core/graphql/filters.py +++ b/netbox/core/graphql/filters.py @@ -1,28 +1,89 @@ -import strawberry_django +from datetime import datetime +from typing import Annotated, TYPE_CHECKING -from core import filtersets, models -from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin +import strawberry +import strawberry_django +from django.contrib.contenttypes.models import ContentType as DjangoContentType +from strawberry.scalars import ID +from strawberry_django import DatetimeFilterLookup, FilterLookup + +from core import models +from core.graphql.filter_mixins import BaseFilterMixin +from netbox.graphql.filter_mixins import PrimaryModelFilterMixin + +if TYPE_CHECKING: + from netbox.graphql.filter_lookups import IntegerLookup, JSONFilter + from users.graphql.filters import UserFilter __all__ = ( 'DataFileFilter', 'DataSourceFilter', 'ObjectChangeFilter', + 'ContentTypeFilter', ) @strawberry_django.filter(models.DataFile, lookups=True) -@autotype_decorator(filtersets.DataFileFilterSet) class DataFileFilter(BaseFilterMixin): - pass + id: ID | None = strawberry_django.filter_field() + created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() + last_updated: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() + source: Annotated['DataSourceFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + source_id: ID | None = strawberry_django.filter_field() + path: FilterLookup[str] | None = strawberry_django.filter_field() + size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + hash: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.DataSource, lookups=True) -@autotype_decorator(filtersets.DataSourceFilterSet) -class DataSourceFilter(BaseFilterMixin): - pass +class DataSourceFilter(PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + type: FilterLookup[str] | None = strawberry_django.filter_field() + source_url: FilterLookup[str] | None = strawberry_django.filter_field() + status: FilterLookup[str] | None = strawberry_django.filter_field() + enabled: FilterLookup[bool] | None = strawberry_django.filter_field() + ignore_rules: FilterLookup[str] | None = strawberry_django.filter_field() + parameters: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + last_synced: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() + datafiles: Annotated['DataFileFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.ObjectChange, lookups=True) -@autotype_decorator(filtersets.ObjectChangeFilterSet) class ObjectChangeFilter(BaseFilterMixin): - pass + id: ID | None = strawberry_django.filter_field() + time: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() + user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() + user_name: FilterLookup[str] | None = strawberry_django.filter_field() + request_id: FilterLookup[str] | None = strawberry_django.filter_field() + action: FilterLookup[str] | None = strawberry_django.filter_field() + changed_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + changed_object_type_id: ID | None = strawberry_django.filter_field() + changed_object_id: ID | None = strawberry_django.filter_field() + related_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + related_object_id: ID | None = strawberry_django.filter_field() + object_repr: FilterLookup[str] | None = strawberry_django.filter_field() + prechange_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + postchange_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter(DjangoContentType, lookups=True) +class ContentTypeFilter(BaseFilterMixin): + id: ID | None = strawberry_django.filter_field() + app_label: FilterLookup[str] | None = strawberry_django.filter_field() + model: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/core/graphql/mixins.py b/netbox/core/graphql/mixins.py index 5195b52a0..72191e6fd 100644 --- a/netbox/core/graphql/mixins.py +++ b/netbox/core/graphql/mixins.py @@ -1,4 +1,4 @@ -from typing import Annotated, List +from typing import Annotated, List, TYPE_CHECKING import strawberry import strawberry_django @@ -6,6 +6,9 @@ from django.contrib.contenttypes.models import ContentType from core.models import ObjectChange +if TYPE_CHECKING: + from netbox.core.graphql.types import ObjectChangeType + __all__ = ( 'ChangelogMixin', ) diff --git a/netbox/core/graphql/types.py b/netbox/core/graphql/types.py index 09385d7c1..ffaa24411 100644 --- a/netbox/core/graphql/types.py +++ b/netbox/core/graphql/types.py @@ -2,12 +2,14 @@ from typing import Annotated, List import strawberry import strawberry_django +from django.contrib.contenttypes.models import ContentType as DjangoContentType from core import models from netbox.graphql.types import BaseObjectType, NetBoxObjectType from .filters import * __all__ = ( + 'ContentType', 'DataFileType', 'DataSourceType', 'ObjectChangeType', @@ -17,7 +19,8 @@ __all__ = ( @strawberry_django.type( models.DataFile, exclude=['data',], - filters=DataFileFilter + filters=DataFileFilter, + pagination=True ) class DataFileType(BaseObjectType): source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] @@ -26,7 +29,8 @@ class DataFileType(BaseObjectType): @strawberry_django.type( models.DataSource, fields='__all__', - filters=DataSourceFilter + filters=DataSourceFilter, + pagination=True ) class DataSourceType(NetBoxObjectType): @@ -36,7 +40,17 @@ class DataSourceType(NetBoxObjectType): @strawberry_django.type( models.ObjectChange, fields='__all__', - filters=ObjectChangeFilter + filters=ObjectChangeFilter, + pagination=True ) class ObjectChangeType(BaseObjectType): pass + + +@strawberry_django.type( + DjangoContentType, + fields='__all__', + pagination=True +) +class ContentType: + pass diff --git a/netbox/core/jobs.py b/netbox/core/jobs.py index d2b846398..b3dfaf1e7 100644 --- a/netbox/core/jobs.py +++ b/netbox/core/jobs.py @@ -1,8 +1,12 @@ import logging +import requests +import sys -from netbox.jobs import JobRunner +from django.conf import settings +from netbox.jobs import JobRunner, system_job from netbox.search.backends import search_backend -from .choices import DataSourceStatusChoices +from utilities.proxy import resolve_proxies +from .choices import DataSourceStatusChoices, JobIntervalChoices from .exceptions import SyncError from .models import DataSource @@ -31,3 +35,44 @@ class SyncDataSourceJob(JobRunner): if type(e) is SyncError: logging.error(e) raise e + + +@system_job(interval=JobIntervalChoices.INTERVAL_DAILY) +class SystemHousekeepingJob(JobRunner): + """ + Perform daily system housekeeping functions. + """ + class Meta: + name = "System Housekeeping" + + def run(self, *args, **kwargs): + # Skip if running in development or test mode + if settings.DEBUG or 'test' in sys.argv: + return + + # TODO: Migrate other housekeeping functions from the `housekeeping` management command. + self.send_census_report() + + @staticmethod + def send_census_report(): + """ + Send a census report (if enabled). + """ + # Skip if census reporting is disabled + if settings.ISOLATED_DEPLOYMENT or not settings.CENSUS_REPORTING_ENABLED: + return + + census_data = { + 'version': settings.RELEASE.full_version, + 'python_version': sys.version.split()[0], + 'deployment_id': settings.DEPLOYMENT_ID, + } + try: + requests.get( + url=settings.CENSUS_URL, + params=census_data, + timeout=3, + proxies=resolve_proxies(url=settings.CENSUS_URL) + ) + except requests.exceptions.RequestException: + pass diff --git a/netbox/core/migrations/0006_datasource_type_remove_choices.py b/netbox/core/migrations/0006_datasource_type_remove_choices.py index 7c9914298..6a7fe2521 100644 --- a/netbox/core/migrations/0006_datasource_type_remove_choices.py +++ b/netbox/core/migrations/0006_datasource_type_remove_choices.py @@ -5,7 +5,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('core', '0005_job_created_auto_now'), + ('core', '0001_squashed_0005'), ] operations = [ diff --git a/netbox/core/migrations/0013_job_data_encoder.py b/netbox/core/migrations/0013_job_data_encoder.py new file mode 100644 index 000000000..b6e567e97 --- /dev/null +++ b/netbox/core/migrations/0013_job_data_encoder.py @@ -0,0 +1,17 @@ +import django.core.serializers.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0012_job_object_type_optional'), + ] + + operations = [ + migrations.AlterField( + model_name='job', + name='data', + field=models.JSONField(blank=True, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True), + ), + ] diff --git a/netbox/core/migrations/0014_datasource_sync_interval.py b/netbox/core/migrations/0014_datasource_sync_interval.py new file mode 100644 index 000000000..4b2e3ddd7 --- /dev/null +++ b/netbox/core/migrations/0014_datasource_sync_interval.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0013_job_data_encoder'), + ] + + operations = [ + migrations.AddField( + model_name='datasource', + name='sync_interval', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/core/migrations/0015_remove_redundant_indexes.py b/netbox/core/migrations/0015_remove_redundant_indexes.py new file mode 100644 index 000000000..b5ff0db6e --- /dev/null +++ b/netbox/core/migrations/0015_remove_redundant_indexes.py @@ -0,0 +1,23 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0014_datasource_sync_interval'), + ] + + operations = [ + migrations.RemoveIndex( + model_name='autosyncrecord', + name='core_autosy_object__c17bac_idx', + ), + migrations.RemoveIndex( + model_name='datafile', + name='core_datafile_source_path', + ), + migrations.RemoveIndex( + model_name='managedfile', + name='core_managedfile_root_path', + ), + ] diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py index 39ee8fa57..52a11c58e 100644 --- a/netbox/core/models/data.py +++ b/netbox/core/models/data.py @@ -59,6 +59,12 @@ class DataSource(JobsMixin, PrimaryModel): verbose_name=_('enabled'), default=True ) + sync_interval = models.PositiveSmallIntegerField( + verbose_name=_('sync interval'), + choices=JobIntervalChoices, + blank=True, + null=True + ) ignore_rules = models.TextField( verbose_name=_('ignore rules'), blank=True, @@ -304,9 +310,6 @@ class DataFile(models.Model): name='%(app_label)s_%(class)s_unique_source_path' ), ) - indexes = [ - models.Index(fields=('source', 'path'), name='core_datafile_source_path'), - ] verbose_name = _('data file') verbose_name_plural = _('data files') @@ -351,17 +354,6 @@ class DataFile(models.Model): return is_modified - def write_to_disk(self, path, overwrite=False): - """ - Write the object's data to disk at the specified path - """ - # Check whether file already exists - if os.path.isfile(path) and not overwrite: - raise FileExistsError() - - with open(path, 'wb+') as new_file: - new_file.write(self.data) - class AutoSyncRecord(models.Model): """ @@ -392,8 +384,5 @@ class AutoSyncRecord(models.Model): name='%(app_label)s_%(class)s_object' ), ) - indexes = ( - models.Index(fields=('object_type', 'object_id')), - ) verbose_name = _('auto sync record') verbose_name_plural = _('auto sync records') diff --git a/netbox/core/models/files.py b/netbox/core/models/files.py index cc446bac7..d60269b8b 100644 --- a/netbox/core/models/files.py +++ b/netbox/core/models/files.py @@ -1,13 +1,16 @@ import logging import os +from functools import cached_property from django.conf import settings from django.core.exceptions import ValidationError from django.db import models +from django.core.files.storage import storages from django.urls import reverse from django.utils.translation import gettext as _ from ..choices import ManagedFileRootPathChoices +from extras.storage import ScriptFileSystemStorage from netbox.models.features import SyncedDataMixin from utilities.querysets import RestrictedQuerySet @@ -55,9 +58,6 @@ class ManagedFile(SyncedDataMixin, models.Model): name='%(app_label)s_%(class)s_unique_root_path' ), ) - indexes = [ - models.Index(fields=('file_root', 'file_path'), name='core_managedfile_root_path'), - ] verbose_name = _('managed file') verbose_name_plural = _('managed files') @@ -76,15 +76,35 @@ class ManagedFile(SyncedDataMixin, models.Model): return os.path.join(self._resolve_root_path(), self.file_path) def _resolve_root_path(self): - return { - 'scripts': settings.SCRIPTS_ROOT, - 'reports': settings.REPORTS_ROOT, - }[self.file_root] + storage = self.storage + if isinstance(storage, ScriptFileSystemStorage): + return { + 'scripts': settings.SCRIPTS_ROOT, + 'reports': settings.REPORTS_ROOT, + }[self.file_root] + else: + return "" def sync_data(self): if self.data_file: self.file_path = os.path.basename(self.data_path) - self.data_file.write_to_disk(self.full_path, overwrite=True) + self._write_to_disk(self.full_path, overwrite=True) + + def _write_to_disk(self, path, overwrite=False): + """ + Write the object's data to disk at the specified path + """ + # Check whether file already exists + storage = self.storage + if storage.exists(path) and not overwrite: + raise FileExistsError() + + with storage.open(path, 'wb+') as new_file: + new_file.write(self.data) + + @cached_property + def storage(self): + return storages.create_storage(storages.backends["scripts"]) def clean(self): super().clean() @@ -104,8 +124,9 @@ class ManagedFile(SyncedDataMixin, models.Model): def delete(self, *args, **kwargs): # Delete file from disk + storage = self.storage try: - os.remove(self.full_path) + storage.delete(self.full_path) except FileNotFoundError: pass diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index 5caa9cc2d..8c704ecad 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -1,11 +1,13 @@ import uuid +from functools import partial import django_rq from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.core.exceptions import ValidationError +from django.core.serializers.json import DjangoJSONEncoder from django.core.validators import MinValueValidator -from django.db import models +from django.db import models, transaction from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext as _ @@ -89,8 +91,9 @@ class Job(models.Model): ) data = models.JSONField( verbose_name=_('data'), + encoder=DjangoJSONEncoder, null=True, - blank=True + blank=True, ) error = models.TextField( verbose_name=_('error'), @@ -258,10 +261,12 @@ class Job(models.Model): # Schedule the job to run at a specific date & time. elif schedule_at: - queue.enqueue_at(schedule_at, func, job_id=str(job.job_id), job=job, **kwargs) + callback = partial(queue.enqueue_at, schedule_at, func, job_id=str(job.job_id), job=job, **kwargs) + transaction.on_commit(callback) # Schedule the job to run asynchronously at this first available opportunity. else: - queue.enqueue(func, job_id=str(job.job_id), job=job, **kwargs) + callback = partial(queue.enqueue, func, job_id=str(job.job_id), job=job, **kwargs) + transaction.on_commit(callback) return job diff --git a/netbox/core/plugins.py b/netbox/core/plugins.py index 1fcb37f2b..0a5bd8fe3 100644 --- a/netbox/core/plugins.py +++ b/netbox/core/plugins.py @@ -9,7 +9,9 @@ from django.conf import settings from django.core.cache import cache from netbox.plugins import PluginConfig +from netbox.registry import registry from utilities.datetime import datetime_from_timestamp +from utilities.proxy import resolve_proxies USER_AGENT_STRING = f'NetBox/{settings.RELEASE.version} {settings.RELEASE.edition}' CACHE_KEY_CATALOG_FEED = 'plugins-catalog-feed' @@ -47,6 +49,7 @@ class Plugin: The representation of a NetBox plugin in the catalog API. """ id: str = '' + icon_url: str = '' status: str = '' title_short: str = '' title_long: str = '' @@ -63,9 +66,11 @@ class Plugin: is_certified: bool = False release_latest: PluginVersion = field(default_factory=PluginVersion) release_recent_history: list[PluginVersion] = field(default_factory=list) - is_local: bool = False # extra field for locally installed plugins - is_installed: bool = False + is_local: bool = False # Indicates that the plugin is listed in settings.PLUGINS (i.e. installed) + is_loaded: bool = False # Indicates whether the plugin successfully loaded at launch installed_version: str = '' + netbox_min_version: str = '' + netbox_max_version: str = '' def get_local_plugins(plugins=None): @@ -79,6 +84,16 @@ def get_local_plugins(plugins=None): for plugin_name in settings.PLUGINS: plugin = importlib.import_module(plugin_name) plugin_config: PluginConfig = plugin.config + installed_version = plugin_config.version + if plugin_config.release_track: + installed_version = f'{installed_version}-{plugin_config.release_track}' + + if plugin_config.author: + author = PluginAuthor( + name=plugin_config.author, + ) + else: + author = None local_plugins[plugin_config.name] = Plugin( config_name=plugin_config.name, @@ -87,19 +102,29 @@ def get_local_plugins(plugins=None): tag_line=plugin_config.description, description_short=plugin_config.description, is_local=True, - is_installed=True, - installed_version=plugin_config.version, + is_loaded=plugin_name in registry['plugins']['installed'], + installed_version=installed_version, + netbox_min_version=plugin_config.min_version, + netbox_max_version=plugin_config.max_version, + author=author, ) # Update catalog entries for local plugins, or add them to the list if not listed for k, v in local_plugins.items(): if k in plugins: - plugins[k].is_local = True - plugins[k].is_installed = True + plugins[k].is_local = v.is_local + plugins[k].is_loaded = v.is_loaded plugins[k].installed_version = v.installed_version else: plugins[k] = v + # Update plugin table config for hidden and static plugins + hidden = settings.PLUGINS_CATALOG_CONFIG.get('hidden', []) + static = settings.PLUGINS_CATALOG_CONFIG.get('static', []) + for k, v in plugins.items(): + v.hidden = k in hidden + v.static = k in static + return plugins @@ -116,10 +141,11 @@ def get_catalog_plugins(): def get_pages(): # TODO: pagination is currently broken in API payload = {'page': '1', 'per_page': '50'} + proxies = resolve_proxies(url=settings.PLUGIN_CATALOG_URL) first_page = session.get( settings.PLUGIN_CATALOG_URL, headers={'User-Agent': USER_AGENT_STRING}, - proxies=settings.HTTP_PROXIES, + proxies=proxies, timeout=3, params=payload ).json() @@ -131,7 +157,7 @@ def get_catalog_plugins(): next_page = session.get( settings.PLUGIN_CATALOG_URL, headers={'User-Agent': USER_AGENT_STRING}, - proxies=settings.HTTP_PROXIES, + proxies=proxies, timeout=3, params=payload ).json() @@ -185,6 +211,7 @@ def get_catalog_plugins(): # Populate plugin data plugins[data['config_name']] = Plugin( id=data['id'], + icon_url=data['icon'], status=data['status'], title_short=data['title_short'], title_long=data['title_long'], diff --git a/netbox/core/signals.py b/netbox/core/signals.py index 06432bf4c..4b537b2d4 100644 --- a/netbox/core/signals.py +++ b/netbox/core/signals.py @@ -2,22 +2,21 @@ import logging from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError -from django.db.models.fields.reverse_related import ManyToManyRel +from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel from django.db.models.signals import m2m_changed, post_save, pre_delete from django.dispatch import receiver, Signal from django.utils.translation import gettext_lazy as _ from django_prometheus.models import model_deletes, model_inserts, model_updates -from core.choices import ObjectChangeActionChoices +from core.choices import JobStatusChoices, ObjectChangeActionChoices from core.events import * -from core.models import ObjectChange from extras.events import enqueue_event from extras.utils import run_validators from netbox.config import get_config from netbox.context import current_request, events_queue from netbox.models.features import ChangeLoggingMixin from utilities.exceptions import AbortRequest -from .models import ConfigRevision +from .models import ConfigRevision, DataSource, ObjectChange __all__ = ( 'clear_events', @@ -146,8 +145,10 @@ def handle_deleted_object(sender, instance, **kwargs): # instance being deleted, and explicitly call .remove() on the remote M2M field to delete # the association. This triggers an m2m_changed signal with the `post_remove` action type # for the forward direction of the relationship, ensuring that the change is recorded. + # Similarly, for many-to-one relationships, we set the value on the related object to None + # and save it to trigger a change record on that object. for relation in instance._meta.related_objects: - if type(relation) is not ManyToManyRel: + if type(relation) not in [ManyToManyRel, ManyToOneRel]: continue related_model = relation.related_model related_field_name = relation.remote_field.name @@ -157,7 +158,11 @@ def handle_deleted_object(sender, instance, **kwargs): continue for obj in related_model.objects.filter(**{related_field_name: instance.pk}): obj.snapshot() # Ensure the change record includes the "before" state - getattr(obj, related_field_name).remove(instance) + if type(relation) is ManyToManyRel: + getattr(obj, related_field_name).remove(instance) + elif type(relation) is ManyToOneRel and relation.field.null is True: + setattr(obj, related_field_name, None) + obj.save() # Enqueue the object for event processing queue = events_queue.get() @@ -182,6 +187,25 @@ def clear_events_queue(sender, **kwargs): # DataSource handlers # +@receiver(post_save, sender=DataSource) +def enqueue_sync_job(instance, created, **kwargs): + """ + When a DataSource is saved, check its sync_interval and enqueue a sync job if appropriate. + """ + from .jobs import SyncDataSourceJob + + if instance.enabled and instance.sync_interval: + SyncDataSourceJob.enqueue_once(instance, interval=instance.sync_interval) + elif not created: + # Delete any previously scheduled recurring jobs for this DataSource + for job in SyncDataSourceJob.get_jobs(instance).defer('data').filter( + interval__isnull=False, + status=JobStatusChoices.STATUS_SCHEDULED + ): + # Call delete() per instance to ensure the associated background task is deleted as well + job.delete() + + @receiver(post_sync) def auto_sync(instance, **kwargs): """ diff --git a/netbox/core/tables/data.py b/netbox/core/tables/data.py index 4059ea9bc..5c6ccebcf 100644 --- a/netbox/core/tables/data.py +++ b/netbox/core/tables/data.py @@ -14,10 +14,10 @@ __all__ = ( class DataSourceTable(NetBoxTable): name = tables.Column( verbose_name=_('Name'), - linkify=True + linkify=True, ) type = BackendTypeColumn( - verbose_name=_('Type') + verbose_name=_('Type'), ) status = columns.ChoiceFieldColumn( verbose_name=_('Status'), @@ -25,20 +25,26 @@ class DataSourceTable(NetBoxTable): enabled = columns.BooleanColumn( verbose_name=_('Enabled'), ) - tags = columns.TagColumn( - url_name='core:datasource_list' + sync_interval = columns.ChoiceFieldColumn( + verbose_name=_('Sync interval'), + ) + last_synced = tables.DateTimeColumn( + verbose_name=_('Last Synced'), ) file_count = tables.Column( - verbose_name='Files' + verbose_name=_('Files'), + ) + tags = columns.TagColumn( + url_name='core:datasource_list', ) class Meta(NetBoxTable.Meta): model = DataSource fields = ( - 'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'comments', 'parameters', - 'created', 'last_updated', 'file_count', + 'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'sync_interval', 'comments', + 'parameters', 'last_synced', 'created', 'last_updated', 'file_count', ) - default_columns = ('pk', 'name', 'type', 'status', 'enabled', 'description', 'file_count') + default_columns = ('pk', 'name', 'type', 'status', 'enabled', 'description', 'sync_interval', 'file_count') class DataFileTable(NetBoxTable): diff --git a/netbox/core/tables/plugins.py b/netbox/core/tables/plugins.py index 96c612366..e1b80af42 100644 --- a/netbox/core/tables/plugins.py +++ b/netbox/core/tables/plugins.py @@ -1,7 +1,10 @@ import django_tables2 as tables +from django.urls import reverse +from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from netbox.tables import BaseTable, columns +from .template_code import PLUGIN_IS_INSTALLED __all__ = ( 'CatalogPluginTable', @@ -9,6 +12,12 @@ __all__ = ( ) +PLUGIN_NAME_TEMPLATE = """ + +{{ record.title_long }} +""" + + class PluginVersionTable(BaseTable): version = tables.Column( verbose_name=_('Version') @@ -39,8 +48,8 @@ class PluginVersionTable(BaseTable): class CatalogPluginTable(BaseTable): - title_long = tables.Column( - linkify=('core:plugin', [tables.A('config_name')]), + title_long = columns.TemplateColumn( + template_code=PLUGIN_NAME_TEMPLATE, verbose_name=_('Name') ) author = tables.Column( @@ -48,12 +57,15 @@ class CatalogPluginTable(BaseTable): verbose_name=_('Author') ) is_local = columns.BooleanColumn( + false_mark=None, verbose_name=_('Local') ) - is_installed = columns.BooleanColumn( - verbose_name=_('Installed') + is_installed = columns.TemplateColumn( + verbose_name=_('Active'), + template_code=PLUGIN_IS_INSTALLED ) is_certified = columns.BooleanColumn( + false_mark=None, verbose_name=_('Certified') ) created_at = columns.DateTimeColumn( @@ -82,3 +94,9 @@ class CatalogPluginTable(BaseTable): # List installed plugins first, then certified plugins, then # everything else (with each tranche ordered alphabetically) order_by = ('-is_installed', '-is_certified', 'name') + + def render_title_long(self, value, record): + if record.static: + return value + url = reverse('core:plugin', args=[record.config_name]) + return mark_safe(f"{value}") diff --git a/netbox/core/tables/template_code.py b/netbox/core/tables/template_code.py index c8f0058e7..9fc652c4c 100644 --- a/netbox/core/tables/template_code.py +++ b/netbox/core/tables/template_code.py @@ -14,3 +14,15 @@ OBJECTCHANGE_OBJECT = """ OBJECTCHANGE_REQUEST_ID = """ {{ value }} """ + +PLUGIN_IS_INSTALLED = """ +{% if record.is_local %} + {% if record.is_loaded %} + + {% else %} + + {% endif %} +{% else %} + +{% endif %} +""" diff --git a/netbox/core/tests/test_filtersets.py b/netbox/core/tests/test_filtersets.py index 310be1d0e..b7dfd516e 100644 --- a/netbox/core/tests/test_filtersets.py +++ b/netbox/core/tests/test_filtersets.py @@ -27,7 +27,8 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests): source_url='file:///var/tmp/source1/', status=DataSourceStatusChoices.NEW, enabled=True, - description='foobar1' + description='foobar1', + sync_interval=JobIntervalChoices.INTERVAL_HOURLY ), DataSource( name='Data Source 2', @@ -35,14 +36,16 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests): source_url='file:///var/tmp/source2/', status=DataSourceStatusChoices.SYNCING, enabled=True, - description='foobar2' + description='foobar2', + sync_interval=JobIntervalChoices.INTERVAL_DAILY ), DataSource( name='Data Source 3', type='git', source_url='https://example.com/git/source3', status=DataSourceStatusChoices.COMPLETED, - enabled=False + enabled=False, + sync_interval=JobIntervalChoices.INTERVAL_WEEKLY ), ) DataSource.objects.bulk_create(data_sources) @@ -73,6 +76,10 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'status': [DataSourceStatusChoices.NEW, DataSourceStatusChoices.SYNCING]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_sync_interval(self): + params = {'sync_interval': [JobIntervalChoices.INTERVAL_HOURLY, JobIntervalChoices.INTERVAL_DAILY]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = DataFile.objects.all() diff --git a/netbox/core/views.py b/netbox/core/views.py index 713807a82..1264c6c1b 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -22,6 +22,7 @@ from rq.worker_registration import clean_worker_registry from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs_from_status, requeue_rq_job, stop_rq_job from netbox.config import get_config, PARAMS +from netbox.registry import registry from netbox.views import generic from netbox.views.generic.base import BaseObjectView from netbox.views.generic.mixins import TableMixin @@ -102,7 +103,7 @@ class DataSourceDeleteView(generic.ObjectDeleteView): queryset = DataSource.objects.all() -@register_model_view(DataSource, 'bulk_import', detail=False) +@register_model_view(DataSource, 'bulk_import', path='import', detail=False) class DataSourceBulkImportView(generic.BulkImportView): queryset = DataSource.objects.all() model_form = forms.DataSourceImportForm @@ -165,7 +166,7 @@ class DataFileBulkDeleteView(generic.BulkDeleteView): @register_model_view(Job, 'list', path='', detail=False) class JobListView(generic.ObjectListView): - queryset = Job.objects.all() + queryset = Job.objects.defer('data') filterset = filtersets.JobFilterSet filterset_form = forms.JobFilterForm table = tables.JobTable @@ -182,12 +183,12 @@ class JobView(generic.ObjectView): @register_model_view(Job, 'delete') class JobDeleteView(generic.ObjectDeleteView): - queryset = Job.objects.all() + queryset = Job.objects.defer('data') @register_model_view(Job, 'bulk_delete', path='delete', detail=False) class JobBulkDeleteView(generic.BulkDeleteView): - queryset = Job.objects.all() + queryset = Job.objects.defer('data') filterset = filtersets.JobFilterSet table = tables.JobTable @@ -560,7 +561,7 @@ class SystemView(UserPassesTestMixin, View): params = [param.name for param in PARAMS] data = { **stats, - 'plugins': settings.PLUGINS, + 'plugins': registry['plugins']['installed'], 'config': { k: getattr(config, k) for k in sorted(params) }, @@ -570,8 +571,9 @@ class SystemView(UserPassesTestMixin, View): return response # Serialize any CustomValidator classes - if hasattr(config, 'CUSTOM_VALIDATORS') and config.CUSTOM_VALIDATORS: - config.CUSTOM_VALIDATORS = json.dumps(config.CUSTOM_VALIDATORS, cls=ConfigJSONEncoder, indent=4) + for attr in ['CUSTOM_VALIDATORS', 'PROTECTION_RULES']: + if hasattr(config, attr) and getattr(config, attr, None): + setattr(config, attr, json.dumps(getattr(config, attr), cls=ConfigJSONEncoder, indent=4)) return render(request, 'core/system.html', { 'stats': stats, @@ -594,7 +596,7 @@ class BasePluginView(UserPassesTestMixin, View): catalog_plugins_error = cache.get(self.CACHE_KEY_CATALOG_ERROR, default=False) if not catalog_plugins_error: catalog_plugins = get_catalog_plugins() - if not catalog_plugins: + if not catalog_plugins and not settings.ISOLATED_DEPLOYMENT: # Cache for 5 minutes to avoid spamming connection cache.set(self.CACHE_KEY_CATALOG_ERROR, True, 300) messages.warning(request, _("Plugins catalog could not be loaded")) @@ -611,6 +613,8 @@ class PluginListView(BasePluginView): if q: plugins = [obj for obj in plugins if q.casefold() in obj.title_short.casefold()] + plugins = [plugin for plugin in plugins if not plugin.hidden] + table = CatalogPluginTable(plugins, user=request.user) table.configure(request) diff --git a/netbox/dcim/api/serializers_/device_components.py b/netbox/dcim/api/serializers_/device_components.py index a6767bb6f..468d75af9 100644 --- a/netbox/dcim/api/serializers_/device_components.py +++ b/netbox/dcim/api/serializers_/device_components.py @@ -1,3 +1,4 @@ +from django.utils.translation import gettext as _ from django.contrib.contenttypes.models import ContentType from drf_spectacular.utils import extend_schema_field from rest_framework import serializers @@ -151,14 +152,15 @@ class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne required=False, allow_null=True ) + status = ChoiceField(choices=PowerOutletStatusChoices, required=False) class Meta: model = PowerOutlet fields = [ - 'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', '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', + 'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'status', 'color', + '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') @@ -232,8 +234,56 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect def validate(self, data): - # Validate many-to-many VLAN assignments if not self.nested: + + # Validate 802.1q mode and vlan(s) + mode = None + tagged_vlans = [] + + # Gather Information + if self.instance: + mode = data.get('mode') if 'mode' in data.keys() else self.instance.mode + untagged_vlan = data.get('untagged_vlan') if 'untagged_vlan' in data.keys() else \ + self.instance.untagged_vlan + qinq_svlan = data.get('qinq_svlan') if 'qinq_svlan' in data.keys() else \ + self.instance.qinq_svlan + tagged_vlans = data.get('tagged_vlans') if 'tagged_vlans' in data.keys() else \ + self.instance.tagged_vlans.all() + else: + mode = data.get('mode', None) + untagged_vlan = data.get('untagged_vlan') if 'untagged_vlan' in data.keys() else None + qinq_svlan = data.get('qinq_svlan') if 'qinq_svlan' in data.keys() else None + tagged_vlans = data.get('tagged_vlans') if 'tagged_vlans' in data.keys() else None + + errors = {} + + # Non Q-in-Q mode with service vlan set + if mode != InterfaceModeChoices.MODE_Q_IN_Q and qinq_svlan: + errors.update({ + 'qinq_svlan': _("Interface mode does not support q-in-q service vlan") + }) + # Routed mode + if not mode: + # Untagged vlan + if untagged_vlan: + errors.update({ + 'untagged_vlan': _("Interface mode does not support untagged vlan") + }) + # Tagged vlan + if tagged_vlans: + errors.update({ + 'tagged_vlans': _("Interface mode does not support tagged vlans") + }) + # Non-tagged mode + elif mode in (InterfaceModeChoices.MODE_TAGGED_ALL, InterfaceModeChoices.MODE_ACCESS) and tagged_vlans: + errors.update({ + 'tagged_vlans': _("Interface mode does not support tagged vlans") + }) + + if errors: + raise serializers.ValidationError(errors) + + # 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]: diff --git a/netbox/dcim/api/serializers_/devices.py b/netbox/dcim/api/serializers_/devices.py index eb6c4f9bc..c1e9c5f51 100644 --- a/netbox/dcim/api/serializers_/devices.py +++ b/netbox/dcim/api/serializers_/devices.py @@ -170,8 +170,8 @@ class MACAddressSerializer(NetBoxModelSerializer): class Meta: model = MACAddress fields = [ - 'id', 'url', 'display_url', 'display', 'mac_address', 'assigned_object_type', 'assigned_object', - 'description', 'comments', + 'id', 'url', 'display_url', 'display', 'mac_address', 'assigned_object_type', 'assigned_object_id', + 'assigned_object', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'mac_address', 'description') diff --git a/netbox/dcim/api/serializers_/devicetypes.py b/netbox/dcim/api/serializers_/devicetypes.py index 0ce2af2f8..61e3833ec 100644 --- a/netbox/dcim/api/serializers_/devicetypes.py +++ b/netbox/dcim/api/serializers_/devicetypes.py @@ -4,8 +4,8 @@ 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 dcim.models import DeviceType, ModuleType, ModuleTypeProfile +from netbox.api.fields import AttributesField, ChoiceField, RelatedObjectCountField from netbox.api.serializers import NetBoxModelSerializer from netbox.choices import * from .manufacturers import ManufacturerSerializer @@ -13,6 +13,7 @@ from .platforms import PlatformSerializer __all__ = ( 'DeviceTypeSerializer', + 'ModuleTypeProfileSerializer', 'ModuleTypeSerializer', ) @@ -62,7 +63,23 @@ class DeviceTypeSerializer(NetBoxModelSerializer): brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count') +class ModuleTypeProfileSerializer(NetBoxModelSerializer): + + class Meta: + model = ModuleTypeProfile + fields = [ + 'id', 'url', 'display_url', 'display', 'name', 'description', 'schema', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') + + class ModuleTypeSerializer(NetBoxModelSerializer): + profile = ModuleTypeProfileSerializer( + nested=True, + required=False, + allow_null=True + ) manufacturer = ManufacturerSerializer( nested=True ) @@ -78,12 +95,17 @@ class ModuleTypeSerializer(NetBoxModelSerializer): required=False, allow_null=True ) + attributes = AttributesField( + source='attribute_data', + required=False, + allow_null=True + ) class Meta: model = ModuleType fields = [ - 'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'part_number', 'airflow', - 'weight', 'weight_unit', 'description', 'comments', 'tags', 'custom_fields', - 'created', 'last_updated', + 'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow', + 'weight', 'weight_unit', 'description', 'attributes', 'comments', 'tags', 'custom_fields', 'created', + 'last_updated', ] - brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'description') + brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description') diff --git a/netbox/dcim/api/serializers_/nested.py b/netbox/dcim/api/serializers_/nested.py index ea346cc63..0e9eaa52f 100644 --- a/netbox/dcim/api/serializers_/nested.py +++ b/netbox/dcim/api/serializers_/nested.py @@ -52,6 +52,13 @@ class NestedLocationSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'rack_count', '_depth'] +class NestedDeviceRoleSerializer(WritableNestedSerializer): + + class Meta: + model = models.DeviceRole + fields = ['id', 'url', 'display_url', 'display', 'name'] + + class NestedDeviceSerializer(WritableNestedSerializer): class Meta: diff --git a/netbox/dcim/api/serializers_/racks.py b/netbox/dcim/api/serializers_/racks.py index 1378c265a..4bc2900dc 100644 --- a/netbox/dcim/api/serializers_/racks.py +++ b/netbox/dcim/api/serializers_/racks.py @@ -70,8 +70,8 @@ class RackTypeSerializer(RackBaseSerializer): model = RackType fields = [ 'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'slug', 'description', 'form_factor', - 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'weight', - 'max_weight', 'weight_unit', 'mounting_depth', 'description', 'comments', 'tags', + 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', + 'outer_unit', 'weight', 'max_weight', 'weight_unit', 'mounting_depth', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description') @@ -129,9 +129,9 @@ class RackSerializer(RackBaseSerializer): fields = [ 'id', 'url', 'display_url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial', 'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'weight', - 'max_weight', 'weight_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', - 'airflow', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', - 'powerfeed_count', + 'max_weight', 'weight_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', + 'mounting_depth', 'airflow', 'description', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', 'device_count', 'powerfeed_count', ] brief_fields = ('id', 'url', 'display', 'name', 'description', 'device_count') diff --git a/netbox/dcim/api/serializers_/roles.py b/netbox/dcim/api/serializers_/roles.py index 8f922da10..17eeaa949 100644 --- a/netbox/dcim/api/serializers_/roles.py +++ b/netbox/dcim/api/serializers_/roles.py @@ -1,7 +1,8 @@ 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 +from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer +from .nested import NestedDeviceRoleSerializer __all__ = ( 'DeviceRoleSerializer', @@ -9,7 +10,8 @@ __all__ = ( ) -class DeviceRoleSerializer(NetBoxModelSerializer): +class DeviceRoleSerializer(NestedGroupModelSerializer): + parent = NestedDeviceRoleSerializer(required=False, allow_null=True, default=None) config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None) # Related object counts @@ -19,10 +21,13 @@ class DeviceRoleSerializer(NetBoxModelSerializer): class Meta: model = DeviceRole fields = [ - 'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', + 'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', 'parent', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', + 'comments', '_depth', ] - brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count') + brief_fields = ( + 'id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count', '_depth' + ) class InventoryItemRoleSerializer(NetBoxModelSerializer): diff --git a/netbox/dcim/api/serializers_/sites.py b/netbox/dcim/api/serializers_/sites.py index b818cd954..90f7b5d35 100644 --- a/netbox/dcim/api/serializers_/sites.py +++ b/netbox/dcim/api/serializers_/sites.py @@ -27,7 +27,7 @@ class RegionSerializer(NestedGroupModelSerializer): model = Region fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'site_count', 'prefix_count', '_depth', + 'created', 'last_updated', 'site_count', 'prefix_count', 'comments', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth') @@ -41,7 +41,7 @@ class SiteGroupSerializer(NestedGroupModelSerializer): model = SiteGroup fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'site_count', 'prefix_count', '_depth', + 'created', 'last_updated', 'site_count', 'prefix_count', 'comments', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth') @@ -93,6 +93,6 @@ class LocationSerializer(NestedGroupModelSerializer): fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'facility', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', - 'prefix_count', '_depth', + 'prefix_count', 'comments', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count', '_depth') diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index fc3740374..734ac13db 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -21,6 +21,7 @@ router.register('rack-reservations', views.RackReservationViewSet) router.register('manufacturers', views.ManufacturerViewSet) router.register('device-types', views.DeviceTypeViewSet) router.register('module-types', views.ModuleTypeViewSet) +router.register('module-type-profiles', views.ModuleTypeProfileViewSet) # Device type components router.register('console-port-templates', views.ConsolePortTemplateViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index d7dbbef91..710e55001 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,3 +1,4 @@ +from django.contrib.contenttypes.prefetch import GenericPrefetch from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404 from drf_spectacular.types import OpenApiTypes @@ -269,6 +270,12 @@ class DeviceTypeViewSet(NetBoxModelViewSet): filterset_class = filtersets.DeviceTypeFilterSet +class ModuleTypeProfileViewSet(NetBoxModelViewSet): + queryset = ModuleTypeProfile.objects.all() + serializer_class = serializers.ModuleTypeProfileSerializer + filterset_class = filtersets.ModuleTypeProfileFilterSet + + class ModuleTypeViewSet(NetBoxModelViewSet): queryset = ModuleType.objects.all() serializer_class = serializers.ModuleTypeSerializer @@ -442,7 +449,18 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet): class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = Interface.objects.prefetch_related( - '_path', 'cable__terminations', + GenericPrefetch( + "cable__terminations__termination", + [ + Interface.objects.select_related("device", "cable"), + ], + ), + GenericPrefetch( + "_path__path_objects", + [ + Interface.objects.select_related("device", "cable"), + ], + ), 'l2vpn_terminations', # Referenced by InterfaceSerializer.l2vpn_termination 'ip_addresses', # Referenced by Interface.count_ipaddresses() 'fhrp_group_assignments', # Referenced by Interface.count_fhrp_groups() diff --git a/netbox/dcim/base_filtersets.py b/netbox/dcim/base_filtersets.py index c007c0120..df2a6b650 100644 --- a/netbox/dcim/base_filtersets.py +++ b/netbox/dcim/base_filtersets.py @@ -53,10 +53,10 @@ class ScopedFilterSet(BaseFilterSet): label=_('Site (slug)'), ) location_id = TreeNodeMultipleChoiceFilter( - queryset=Location.objects.all(), - field_name='_location', - lookup_expr='in', - label=_('Location (ID)'), + queryset=Location.objects.all(), + field_name='_location', + lookup_expr='in', + label=_('Location (ID)'), ) location = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index c5b6cbcad..267966e10 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -128,14 +128,15 @@ class RackElevationDetailRenderChoices(ChoiceSet): class RackAirflowChoices(ChoiceSet): + key = 'Rack.airflow' FRONT_TO_REAR = 'front-to-rear' REAR_TO_FRONT = 'rear-to-front' - CHOICES = ( + CHOICES = [ (FRONT_TO_REAR, _('Front to rear')), (REAR_TO_FRONT, _('Rear to front')), - ) + ] # @@ -191,6 +192,7 @@ class DeviceStatusChoices(ChoiceSet): class DeviceAirflowChoices(ChoiceSet): + key = 'Device.airflow' AIRFLOW_FRONT_TO_REAR = 'front-to-rear' AIRFLOW_REAR_TO_FRONT = 'rear-to-front' @@ -203,7 +205,7 @@ class DeviceAirflowChoices(ChoiceSet): AIRFLOW_PASSIVE = 'passive' AIRFLOW_MIXED = 'mixed' - CHOICES = ( + CHOICES = [ (AIRFLOW_FRONT_TO_REAR, _('Front to rear')), (AIRFLOW_REAR_TO_FRONT, _('Rear to front')), (AIRFLOW_LEFT_TO_RIGHT, _('Left to right')), @@ -214,7 +216,7 @@ class DeviceAirflowChoices(ChoiceSet): (AIRFLOW_TOP_TO_BOTTOM, _('Top to bottom')), (AIRFLOW_PASSIVE, _('Passive')), (AIRFLOW_MIXED, _('Mixed')), - ) + ] # @@ -242,6 +244,7 @@ class ModuleStatusChoices(ChoiceSet): class ModuleAirflowChoices(ChoiceSet): + key = 'Module.airflow' FRONT_TO_REAR = 'front-to-rear' REAR_TO_FRONT = 'rear-to-front' @@ -250,14 +253,14 @@ class ModuleAirflowChoices(ChoiceSet): SIDE_TO_REAR = 'side-to-rear' PASSIVE = 'passive' - CHOICES = ( + CHOICES = [ (FRONT_TO_REAR, _('Front to rear')), (REAR_TO_FRONT, _('Rear to front')), (LEFT_TO_RIGHT, _('Left to right')), (RIGHT_TO_LEFT, _('Right to left')), (SIDE_TO_REAR, _('Side to rear')), (PASSIVE, _('Passive')), - ) + ] # @@ -986,6 +989,7 @@ class InterfaceTypeChoices(ChoiceSet): # Coaxial TYPE_DOCSIS = 'docsis' + TYPE_MOCA = 'moca' # PON TYPE_BPON = 'bpon' @@ -1182,6 +1186,7 @@ class InterfaceTypeChoices(ChoiceSet): _('Coaxial'), ( (TYPE_DOCSIS, 'DOCSIS'), + (TYPE_MOCA, 'MoCA'), ) ), ( @@ -1345,6 +1350,9 @@ class PortTypeChoices(ChoiceSet): TYPE_SC_UPC = 'sc-upc' TYPE_SC_APC = 'sc-apc' TYPE_FC = 'fc' + TYPE_FC_PC = 'fc-pc' + TYPE_FC_UPC = 'fc-upc' + TYPE_FC_APC = 'fc-apc' TYPE_LC = 'lc' TYPE_LC_PC = 'lc-pc' TYPE_LC_UPC = 'lc-upc' @@ -1405,6 +1413,9 @@ class PortTypeChoices(ChoiceSet): _('Fiber Optic'), ( (TYPE_FC, 'FC'), + (TYPE_FC_PC, 'FC/PC'), + (TYPE_FC_UPC, 'FC/UPC'), + (TYPE_FC_APC, 'FC/APC'), (TYPE_LC, 'LC'), (TYPE_LC_PC, 'LC/PC'), (TYPE_LC_UPC, 'LC/UPC'), @@ -1627,6 +1638,23 @@ class PowerFeedPhaseChoices(ChoiceSet): ) +# +# PowerOutlets +# +class PowerOutletStatusChoices(ChoiceSet): + key = 'PowerOutlet.status' + + STATUS_ENABLED = 'enabled' + STATUS_DISABLED = 'disabled' + STATUS_FAULTY = 'faulty' + + CHOICES = [ + (STATUS_ENABLED, _('Enabled'), 'green'), + (STATUS_DISABLED, _('Disabled'), 'red'), + (STATUS_FAULTY, _('Faulty'), 'gray'), + ] + + # # VDC # diff --git a/netbox/dcim/exceptions.py b/netbox/dcim/exceptions.py new file mode 100644 index 000000000..e4be1b5f1 --- /dev/null +++ b/netbox/dcim/exceptions.py @@ -0,0 +1,2 @@ +class UnsupportedCablePath(Exception): + pass diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 90a9993c2..a31cf136d 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -11,7 +11,8 @@ from ipam.filtersets import PrimaryIPFilterSet from ipam.models import ASN, IPAddress, VLANTranslationPolicy, VRF from netbox.choices import ColorChoices from netbox.filtersets import ( - BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet, + AttributeFiltersMixin, BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet, NetBoxModelFilterSet, + OrganizationalModelFilterSet, ) from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet from tenancy.models import * @@ -58,6 +59,7 @@ __all__ = ( 'ModuleBayTemplateFilterSet', 'ModuleFilterSet', 'ModuleTypeFilterSet', + 'ModuleTypeProfileFilterSet', 'PathEndpointFilterSet', 'PlatformFilterSet', 'PowerConnectionFilterSet', @@ -81,7 +83,7 @@ __all__ = ( ) -class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): +class RegionFilterSet(NestedGroupModelFilterSet, ContactModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=Region.objects.all(), label=_('Parent region (ID)'), @@ -111,7 +113,7 @@ class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): fields = ('id', 'name', 'slug', 'description') -class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): +class SiteGroupFilterSet(NestedGroupModelFilterSet, ContactModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=SiteGroup.objects.all(), label=_('Parent site group (ID)'), @@ -205,7 +207,7 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe return queryset.filter(qs_filter).distinct() -class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalModelFilterSet): +class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, NestedGroupModelFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', @@ -275,13 +277,13 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM fields = ('id', 'name', 'slug', '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) - ) + # extended in order to include querying on Location.facility + queryset = super().search(queryset, name, value) + + if value.strip(): + queryset = queryset | queryset.model.objects.filter(facility__icontains=value) + + return queryset class RackRoleFilterSet(OrganizationalModelFilterSet): @@ -312,8 +314,8 @@ class RackTypeFilterSet(NetBoxModelFilterSet): class Meta: model = RackType fields = ( - 'id', 'model', 'slug', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', - 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', + 'id', 'model', 'slug', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height', + 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', ) def search(self, queryset, name, value): @@ -425,8 +427,8 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe model = Rack fields = ( 'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'starting_unit', 'desc_units', 'outer_width', - 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', - 'description', + 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight', + 'weight_unit', 'description', ) def search(self, queryset, name, value): @@ -673,7 +675,33 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): return queryset.exclude(inventoryitemtemplates__isnull=value) -class ModuleTypeFilterSet(NetBoxModelFilterSet): +class ModuleTypeProfileFilterSet(NetBoxModelFilterSet): + + class Meta: + model = ModuleTypeProfile + fields = ('id', 'name', 'description') + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) | + Q(comments__icontains=value) + ) + + +class ModuleTypeFilterSet(AttributeFiltersMixin, NetBoxModelFilterSet): + profile_id = django_filters.ModelMultipleChoiceFilter( + queryset=ModuleTypeProfile.objects.all(), + label=_('Profile (ID)'), + ) + profile = django_filters.ModelMultipleChoiceFilter( + field_name='profile__name', + queryset=ModuleTypeProfile.objects.all(), + to_field_name='name', + label=_('Profile (name)'), + ) manufacturer_id = django_filters.ModelMultipleChoiceFilter( queryset=Manufacturer.objects.all(), label=_('Manufacturer (ID)'), @@ -921,6 +949,29 @@ class DeviceRoleFilterSet(OrganizationalModelFilterSet): queryset=ConfigTemplate.objects.all(), label=_('Config template (ID)'), ) + parent_id = django_filters.ModelMultipleChoiceFilter( + queryset=DeviceRole.objects.all(), + label=_('Parent device role (ID)'), + ) + parent = django_filters.ModelMultipleChoiceFilter( + field_name='parent__slug', + queryset=DeviceRole.objects.all(), + to_field_name='slug', + label=_('Parent device role (slug)'), + ) + ancestor_id = TreeNodeMultipleChoiceFilter( + queryset=DeviceRole.objects.all(), + field_name='parent', + lookup_expr='in', + label=_('Parent device role (ID)'), + ) + ancestor = TreeNodeMultipleChoiceFilter( + queryset=DeviceRole.objects.all(), + field_name='parent', + lookup_expr='in', + to_field_name='slug', + label=_('Parent device role (slug)'), + ) class Meta: model = DeviceRole @@ -989,14 +1040,16 @@ class DeviceFilterSet( queryset=DeviceType.objects.all(), label=_('Device type (ID)'), ) - role_id = django_filters.ModelMultipleChoiceFilter( - field_name='role_id', + role_id = TreeNodeMultipleChoiceFilter( + field_name='role', queryset=DeviceRole.objects.all(), + lookup_expr='in', label=_('Role (ID)'), ) - role = django_filters.ModelMultipleChoiceFilter( - field_name='role__slug', + role = TreeNodeMultipleChoiceFilter( queryset=DeviceRole.objects.all(), + field_name='role', + lookup_expr='in', to_field_name='slug', label=_('Role (slug)'), ) @@ -1057,6 +1110,13 @@ class DeviceFilterSet( lookup_expr='in', label=_('Location (ID)'), ) + location = TreeNodeMultipleChoiceFilter( + queryset=Location.objects.all(), + field_name='location', + lookup_expr='in', + to_field_name='slug', + label=_('Location (slug)'), + ) rack_id = django_filters.ModelMultipleChoiceFilter( field_name='rack', queryset=Rack.objects.all(), @@ -1193,6 +1253,7 @@ class DeviceFilterSet( return queryset return queryset.filter( Q(name__icontains=value) | + Q(virtual_chassis__name__icontains=value) | Q(serial__icontains=value.strip()) | Q(inventoryitems__serial__icontains=value.strip()) | Q(asset_tag__icontains=value.strip()) | @@ -1329,10 +1390,75 @@ class ModuleFilterSet(NetBoxModelFilterSet): lookup_expr='in', label=_('Module bay (ID)'), ) + region_id = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='device__site__region', + lookup_expr='in', + label=_('Region (ID)'), + ) + region = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='device__site__region', + lookup_expr='in', + to_field_name='slug', + label=_('Region (slug)'), + ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='device__site__group', + lookup_expr='in', + label=_('Site group (ID)'), + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='device__site__group', + lookup_expr='in', + to_field_name='slug', + label=_('Site group (slug)'), + ) + site_id = django_filters.ModelMultipleChoiceFilter( + field_name='device__site', + queryset=Site.objects.all(), + label=_('Site (ID)'), + ) + site = django_filters.ModelMultipleChoiceFilter( + field_name='device__site__slug', + queryset=Site.objects.all(), + to_field_name='slug', + label=_('Site name (slug)'), + ) + location_id = django_filters.ModelMultipleChoiceFilter( + field_name='device__location', + queryset=Location.objects.all(), + label=_('Location (ID)'), + ) + location = django_filters.ModelMultipleChoiceFilter( + field_name='device__location__slug', + queryset=Location.objects.all(), + to_field_name='slug', + label=_('Location (slug)'), + ) + rack_id = django_filters.ModelMultipleChoiceFilter( + field_name='device__rack', + queryset=Rack.objects.all(), + label=_('Rack (ID)'), + ) + rack = django_filters.ModelMultipleChoiceFilter( + field_name='device__rack__name', + queryset=Rack.objects.all(), + to_field_name='name', + label=_('Rack (name)'), + ) device_id = django_filters.ModelMultipleChoiceFilter( queryset=Device.objects.all(), label=_('Device (ID)'), ) + device = django_filters.ModelMultipleChoiceFilter( + field_name='device__name', + queryset=Device.objects.all(), + to_field_name='name', + label=_('Device (name)'), + ) status = django_filters.MultipleChoiceFilter( choices=ModuleStatusChoices, null_value=None @@ -1591,11 +1717,15 @@ class PowerOutletFilterSet( queryset=PowerPort.objects.all(), label=_('Power port (ID)'), ) + status = django_filters.MultipleChoiceFilter( + choices=PowerOutletStatusChoices, + null_value=None + ) class Meta: model = PowerOutlet fields = ( - 'id', 'name', 'label', 'feed_leg', 'description', 'color', 'mark_connected', 'cable_end', + 'id', 'name', 'status', 'label', 'feed_leg', 'description', 'color', 'mark_connected', 'cable_end', ) @@ -1652,8 +1782,8 @@ class MACAddressFilterSet(NetBoxModelFilterSet): if not value.strip(): return queryset qs_filter = ( - Q(mac_address__icontains=value) | - Q(description__icontains=value) + Q(mac_address__icontains=value) | + Q(description__icontains=value) ) return queryset.filter(qs_filter) @@ -1681,6 +1811,10 @@ class MACAddressFilterSet(NetBoxModelFilterSet): class CommonInterfaceFilterSet(django_filters.FilterSet): + mode = django_filters.MultipleChoiceFilter( + choices=InterfaceModeChoices, + label=_('802.1Q Mode') + ) vlan_id = django_filters.CharFilter( method='filter_vlan_id', label=_('Assigned VLAN') diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py index 0ea3aee63..efcaf4903 100644 --- a/netbox/dcim/forms/bulk_create.py +++ b/netbox/dcim/forms/bulk_create.py @@ -121,11 +121,11 @@ class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm): class InventoryItemBulkCreateForm( - form_from_model(InventoryItem, ['role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered']), + form_from_model(InventoryItem, ['status', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered']), DeviceBulkAddComponentForm ): model = InventoryItem field_order = ( - 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', + 'name', 'label', 'status', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', 'tags', ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index da5a45f15..098c1a58e 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -14,7 +14,9 @@ from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from users.models import User from utilities.forms import BulkEditForm, add_blank_choice, form_from_model -from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField +from utilities.forms.fields import ( + ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, +) from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions from virtualization.models import Cluster @@ -46,6 +48,7 @@ __all__ = ( 'ModuleBayBulkEditForm', 'ModuleBayTemplateBulkEditForm', 'ModuleTypeBulkEditForm', + 'ModuleTypeProfileBulkEditForm', 'PlatformBulkEditForm', 'PowerFeedBulkEditForm', 'PowerOutletBulkEditForm', @@ -78,12 +81,13 @@ class RegionBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + comments = CommentField() model = Region fieldsets = ( FieldSet('parent', 'description'), ) - nullable_fields = ('parent', 'description') + nullable_fields = ('parent', 'description', 'comments') class SiteGroupBulkEditForm(NetBoxModelBulkEditForm): @@ -97,12 +101,13 @@ class SiteGroupBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + comments = CommentField() model = SiteGroup fieldsets = ( FieldSet('parent', 'description'), ) - nullable_fields = ('parent', 'description') + nullable_fields = ('parent', 'description', 'comments') class SiteBulkEditForm(NetBoxModelBulkEditForm): @@ -197,12 +202,13 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + comments = CommentField() model = Location fieldsets = ( FieldSet('site', 'parent', 'status', 'tenant', 'description'), ) - nullable_fields = ('parent', 'tenant', 'description') + nullable_fields = ('parent', 'tenant', 'description', 'comments') class RackRoleBulkEditForm(NetBoxModelBulkEditForm): @@ -257,6 +263,11 @@ class RackTypeBulkEditForm(NetBoxModelBulkEditForm): required=False, min_value=1 ) + outer_height = forms.IntegerField( + label=_('Outer height'), + required=False, + min_value=1 + ) outer_depth = forms.IntegerField( label=_('Outer depth'), required=False, @@ -299,7 +310,7 @@ class RackTypeBulkEditForm(NetBoxModelBulkEditForm): fieldsets = ( FieldSet('manufacturer', 'description', 'form_factor', 'width', 'u_height', name=_('Rack Type')), FieldSet( - InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')), + InlineFields('outer_width', 'outer_height', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')), InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')), 'mounting_depth', name=_('Dimensions') @@ -307,7 +318,7 @@ class RackTypeBulkEditForm(NetBoxModelBulkEditForm): FieldSet('starting_unit', 'desc_units', name=_('Numbering')), ) nullable_fields = ( - 'outer_width', 'outer_depth', 'outer_unit', 'weight', + 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', ) @@ -401,6 +412,11 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): required=False, min_value=1 ) + outer_height = forms.IntegerField( + label=_('Outer height'), + required=False, + min_value=1 + ) outer_depth = forms.IntegerField( label=_('Outer depth'), required=False, @@ -448,15 +464,13 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): fieldsets = ( FieldSet('status', 'role', 'tenant', 'serial', 'asset_tag', 'rack_type', 'description', name=_('Rack')), FieldSet('region', 'site_group', 'site', 'location', name=_('Location')), - FieldSet( - 'form_factor', 'width', 'u_height', 'desc_units', 'airflow', 'outer_width', 'outer_depth', 'outer_unit', - 'mounting_depth', name=_('Hardware') - ), + FieldSet('outer_width', 'outer_height', 'outer_depth', 'outer_unit', name=_('Outer Dimensions')), + FieldSet('form_factor', 'width', 'u_height', 'desc_units', 'airflow', 'mounting_depth', 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', - 'max_weight', 'weight_unit', 'description', 'comments', + 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_height', 'outer_depth', + 'outer_unit', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', ) @@ -563,7 +577,31 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments') +class ModuleTypeProfileBulkEditForm(NetBoxModelBulkEditForm): + schema = JSONField( + label=_('Schema'), + required=False + ) + description = forms.CharField( + label=_('Description'), + max_length=200, + required=False + ) + comments = CommentField() + + model = ModuleTypeProfile + fieldsets = ( + FieldSet('name', 'description', 'schema', name=_('Profile')), + ) + nullable_fields = ('description', 'comments') + + class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): + profile = DynamicModelChoiceField( + label=_('Profile'), + queryset=ModuleTypeProfile.objects.all(), + required=False + ) manufacturer = DynamicModelChoiceField( label=_('Manufacturer'), queryset=Manufacturer.objects.all(), @@ -598,17 +636,22 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): model = ModuleType fieldsets = ( - FieldSet('manufacturer', 'part_number', 'description', name=_('Module Type')), + FieldSet('profile', 'manufacturer', 'part_number', 'description', name=_('Module Type')), FieldSet( 'airflow', InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')), name=_('Chassis') ), ) - nullable_fields = ('part_number', 'weight', 'weight_unit', 'description', 'comments') + nullable_fields = ('part_number', 'weight', 'weight_unit', 'profile', 'description', 'comments') class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): + parent = DynamicModelChoiceField( + label=_('Parent'), + queryset=DeviceRole.objects.all(), + required=False, + ) color = ColorField( label=_('Color'), required=False @@ -628,12 +671,13 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + comments = CommentField() model = DeviceRole fieldsets = ( - FieldSet('color', 'vm_role', 'config_template', 'description'), + FieldSet('parent', 'color', 'vm_role', 'config_template', 'description'), ) - nullable_fields = ('color', 'config_template', 'description') + nullable_fields = ('parent', 'color', 'config_template', 'description', 'comments') class PlatformBulkEditForm(NetBoxModelBulkEditForm): @@ -1379,7 +1423,10 @@ class PowerPortBulkEditForm( class PowerOutletBulkEditForm( ComponentBulkEditForm, - form_from_model(PowerOutlet, ['label', 'type', 'color', 'feed_leg', 'power_port', 'mark_connected', 'description']) + form_from_model( + PowerOutlet, + ['label', 'type', 'status', 'color', 'feed_leg', 'power_port', 'mark_connected', 'description'] + ) ): mark_connected = forms.NullBooleanField( label=_('Mark connected'), @@ -1389,7 +1436,7 @@ class PowerOutletBulkEditForm( model = PowerOutlet fieldsets = ( - FieldSet('module', 'type', 'label', 'description', 'mark_connected', 'color'), + FieldSet('module', 'type', 'label', 'status', 'description', 'mark_connected', 'color'), FieldSet('feed_leg', 'power_port', name=_('Power')), ) nullable_fields = ('module', 'label', 'type', 'feed_leg', 'power_port', 'description') @@ -1411,7 +1458,7 @@ class InterfaceBulkEditForm( form_from_model(Interface, [ 'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', - 'wireless_lans' + 'wireless_lans', 'vlan_translation_policy' ]) ): enabled = forms.NullBooleanField( @@ -1564,7 +1611,9 @@ class InterfaceBulkEditForm( 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', 'qinq_svlan', name=_('802.1Q Switching')), + FieldSet( + 'mode', 'vlan_group', 'untagged_vlan', 'qinq_svlan', 'vlan_translation_policy', name=_('802.1Q Switching') + ), FieldSet( TabbedGroups( FieldSet('tagged_vlans', name=_('Assignment')), @@ -1579,7 +1628,7 @@ class InterfaceBulkEditForm( nullable_fields = ( 'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'vdcs', 'mtu', 'description', 'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', - 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'wireless_lans' + 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'wireless_lans', 'vlan_translation_policy', ) def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 92f7220da..3ad4ced91 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -39,6 +39,7 @@ __all__ = ( 'ModuleImportForm', 'ModuleBayImportForm', 'ModuleTypeImportForm', + 'ModuleTypeProfileImportForm', 'PlatformImportForm', 'PowerFeedImportForm', 'PowerOutletImportForm', @@ -68,7 +69,7 @@ class RegionImportForm(NetBoxModelImportForm): class Meta: model = Region - fields = ('name', 'slug', 'parent', 'description', 'tags') + fields = ('name', 'slug', 'parent', 'description', 'tags', 'comments') class SiteGroupImportForm(NetBoxModelImportForm): @@ -82,7 +83,7 @@ class SiteGroupImportForm(NetBoxModelImportForm): class Meta: model = SiteGroup - fields = ('name', 'slug', 'parent', 'description') + fields = ('name', 'slug', 'parent', 'description', 'comments', 'tags') class SiteImportForm(NetBoxModelImportForm): @@ -160,7 +161,10 @@ class LocationImportForm(NetBoxModelImportForm): class Meta: model = Location - fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'facility', 'description', 'tags') + fields = ( + 'site', 'parent', 'name', 'slug', 'status', 'tenant', 'facility', 'description', + 'tags', 'comments', + ) def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) @@ -219,7 +223,7 @@ class RackTypeImportForm(NetBoxModelImportForm): model = RackType fields = ( 'manufacturer', 'model', 'slug', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', - 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', + 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags', ) @@ -304,7 +308,7 @@ class RackImportForm(NetBoxModelImportForm): model = Rack fields = ( 'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'rack_type', 'form_factor', 'serial', - 'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', + 'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags', ) @@ -424,7 +428,22 @@ class DeviceTypeImportForm(NetBoxModelImportForm): ] +class ModuleTypeProfileImportForm(NetBoxModelImportForm): + + class Meta: + model = ModuleTypeProfile + fields = [ + 'name', 'description', 'schema', 'comments', 'tags', + ] + + class ModuleTypeImportForm(NetBoxModelImportForm): + profile = forms.ModelChoiceField( + label=_('Profile'), + queryset=ModuleTypeProfile.objects.all(), + to_field_name='name', + required=False + ) manufacturer = forms.ModelChoiceField( label=_('Manufacturer'), queryset=Manufacturer.objects.all(), @@ -457,6 +476,16 @@ class ModuleTypeImportForm(NetBoxModelImportForm): class DeviceRoleImportForm(NetBoxModelImportForm): + parent = CSVModelChoiceField( + label=_('Parent'), + queryset=DeviceRole.objects.all(), + required=False, + to_field_name='name', + help_text=_('Parent Device Role'), + error_messages={ + 'invalid_choice': _('Device role not found.'), + } + ) config_template = CSVModelChoiceField( label=_('Config template'), queryset=ConfigTemplate.objects.all(), @@ -468,7 +497,9 @@ class DeviceRoleImportForm(NetBoxModelImportForm): class Meta: model = DeviceRole - fields = ('name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags') + fields = ( + 'name', 'slug', 'parent', 'color', 'vm_role', 'config_template', 'description', 'comments', 'tags' + ) class PlatformImportForm(NetBoxModelImportForm): @@ -1161,27 +1192,45 @@ class InventoryItemImportForm(NetBoxModelImportForm): else: self.fields['parent'].queryset = InventoryItem.objects.none() - def clean_component_name(self): - content_type = self.cleaned_data.get('component_type') - component_name = self.cleaned_data.get('component_name') + def clean(self): + super().clean() + cleaned_data = self.cleaned_data + component_type = cleaned_data.get('component_type') + component_name = cleaned_data.get('component_name') device = self.cleaned_data.get("device") - if not device and hasattr(self, 'instance') and hasattr(self.instance, 'device'): - device = self.instance.device - - if not all([device, content_type, component_name]): - return None - - model = content_type.model_class() - try: - component = model.objects.get(device=device, name=component_name) - self.instance.component = component - except ObjectDoesNotExist: - raise forms.ValidationError( - _("Component not found: {device} - {component_name}").format( - device=device, component_name=component_name + if component_type: + if device is None: + cleaned_data.pop('component_type', None) + if component_name is None: + cleaned_data.pop('component_type', None) + raise forms.ValidationError( + _("Component name must be specified when component type is specified") ) - ) + if all([device, component_name]): + try: + model = component_type.model_class() + self.instance.component = model.objects.get(device=device, name=component_name) + except ObjectDoesNotExist: + cleaned_data.pop('component_type', None) + cleaned_data.pop('component_name', None) + raise forms.ValidationError( + _("Component not found: {device} - {component_name}").format( + device=device, component_name=component_name + ) + ) + else: + cleaned_data.pop('component_type', None) + if not component_name: + raise forms.ValidationError( + _("Component name must be specified when component type is specified") + ) + else: + if component_name: + raise forms.ValidationError( + _("Component type must be specified when component name is specified") + ) + return cleaned_data # diff --git a/netbox/dcim/forms/common.py b/netbox/dcim/forms/common.py index 8ca258f34..a3a781be5 100644 --- a/netbox/dcim/forms/common.py +++ b/netbox/dcim/forms/common.py @@ -41,22 +41,15 @@ class InterfaceCommonForm(forms.Form): def clean(self): super().clean() - parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine' - tagged_vlans = self.cleaned_data.get('tagged_vlans') - - # Untagged interfaces cannot be assigned tagged VLANs - if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and tagged_vlans: - raise forms.ValidationError({ - 'mode': _("An access interface cannot have tagged VLANs assigned.") - }) - - # Remove all tagged VLAN assignments from "tagged all" interfaces - elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED_ALL: - self.cleaned_data['tagged_vlans'] = [] + if 'tagged_vlans' in self.fields.keys(): + tagged_vlans = self.cleaned_data.get('tagged_vlans') if self.is_bound else \ + self.get_initial_for_field(self.fields['tagged_vlans'], 'tagged_vlans') + else: + tagged_vlans = [] # Validate tagged VLANs; must be a global VLAN or in the same site - elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED and tagged_vlans: + if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED and tagged_vlans: valid_sites = [None, self.cleaned_data[parent_field].site] invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites] @@ -67,6 +60,12 @@ class InterfaceCommonForm(forms.Form): "or they must be global" ).format(vlans=', '.join(invalid_vlans)) }) + # Validate mode change + if self.instance.pk and (self.instance.mode != self.cleaned_data['mode']): + if 'untagged_vlan' not in self.cleaned_data and self.instance.untagged_vlan is not None: + self.instance.untagged_vlan = None + if 'tagged_vlans' not in self.cleaned_data and self.instance.tagged_vlans is not None: + self.instance.tagged_vlans.clear() class ModuleCommonForm(forms.Form): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 37b8afd17..813a578d6 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -6,7 +6,7 @@ from dcim.constants import * from dcim.models import * from extras.forms import LocalConfigContextFilterForm from extras.models import ConfigTemplate -from ipam.models import ASN, VRF +from ipam.models import ASN, VRF, VLANTranslationPolicy from netbox.choices import * from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import ContactModelFilterForm, TenancyFilterForm @@ -39,6 +39,7 @@ __all__ = ( 'ModuleFilterForm', 'ModuleBayFilterForm', 'ModuleTypeFilterForm', + 'ModuleTypeProfileFilterForm', 'PlatformFilterForm', 'PowerConnectionFilterForm', 'PowerFeedFilterForm', @@ -303,7 +304,7 @@ class RackTypeFilterForm(RackBaseFilterForm): model = RackType fieldsets = ( FieldSet('q', 'filter_id', 'tag'), - FieldSet('form_factor', 'width', 'u_height', name=_('Rack Type')), + FieldSet('manufacturer_id', 'form_factor', 'width', 'u_height', name=_('Rack Type')), FieldSet('starting_unit', 'desc_units', name=_('Numbering')), FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')), ) @@ -602,11 +603,19 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): ) +class ModuleTypeProfileFilterForm(NetBoxModelFilterSetForm): + model = ModuleTypeProfile + fieldsets = ( + FieldSet('q', 'filter_id', 'tag'), + ) + selector_fields = ('filter_id', 'q') + + class ModuleTypeFilterForm(NetBoxModelFilterSetForm): model = ModuleType fieldsets = ( FieldSet('q', 'filter_id', 'tag'), - FieldSet('manufacturer_id', 'part_number', 'airflow', name=_('Hardware')), + FieldSet('profile_id', 'manufacturer_id', 'part_number', 'airflow', name=_('Hardware')), FieldSet( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', name=_('Components') @@ -614,6 +623,11 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm): FieldSet('weight', 'weight_unit', name=_('Weight')), ) selector_fields = ('filter_id', 'q', 'manufacturer_id') + profile_id = DynamicModelMultipleChoiceField( + queryset=ModuleTypeProfile.objects.all(), + required=False, + label=_('Profile') + ) manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), required=False, @@ -689,6 +703,11 @@ class DeviceRoleFilterForm(NetBoxModelFilterSetForm): required=False, label=_('Config template') ) + parent_id = DynamicModelMultipleChoiceField( + queryset=DeviceRole.objects.all(), + required=False, + label=_('Parent') + ) tag = TagFilterField(model) @@ -940,8 +959,56 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo model = Module fieldsets = ( FieldSet('q', 'filter_id', 'tag'), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')), FieldSet('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag', name=_('Hardware')), ) + device_id = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + query_params={ + 'site_id': '$site_id', + 'location_id': '$location_id', + 'rack_id': '$rack_id', + }, + label=_('Device') + ) + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region') + ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region_id', + 'group_id': '$site_group_id', + }, + label=_('Site') + ) + location_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), + required=False, + query_params={ + 'site_id': '$site_id', + }, + label=_('Location') + ) + rack_id = DynamicModelMultipleChoiceField( + queryset=Rack.objects.all(), + required=False, + label=_('Rack'), + null_option='None', + query_params={ + 'site_id': '$site_id', + 'location_id': '$location_id', + } + ) manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), required=False, @@ -1305,7 +1372,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = PowerOutlet fieldsets = ( FieldSet('q', 'filter_id', 'tag'), - FieldSet('name', 'label', 'type', 'color', name=_('Attributes')), + FieldSet('name', 'label', 'type', 'color', 'status', 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', 'device_status', 'virtual_chassis_id', @@ -1323,6 +1390,11 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): label=_('Color'), required=False ) + status = forms.MultipleChoiceField( + label=_('Status'), + choices=PowerOutletStatusChoices, + required=False + ) class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): @@ -1332,6 +1404,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): 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('mode', 'vlan_translation_policy_id', name=_('802.1Q Switching')), 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( @@ -1403,6 +1476,16 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): required=False, label=_('PoE type') ) + mode = forms.MultipleChoiceField( + choices=InterfaceModeChoices, + required=False, + label=_('802.1Q mode') + ) + vlan_translation_policy_id = DynamicModelMultipleChoiceField( + queryset=VLANTranslationPolicy.objects.all(), + required=False, + label=_('VLAN Translation Policy') + ) rf_role = forms.MultipleChoiceField( choices=WirelessRoleChoices, required=False, diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 9bc69e991..d8cff372f 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1,5 +1,6 @@ from django import forms from django.contrib.contenttypes.models import ContentType +from django.core.validators import EMPTY_VALUES from django.utils.translation import gettext_lazy as _ from timezone_field import TimeZoneFormField @@ -18,6 +19,7 @@ from utilities.forms.fields import ( ) from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK +from utilities.jsonschema import JSONSchemaProperty from virtualization.models import Cluster, VMInterface from wireless.models import WirelessLAN, WirelessLANGroup from .common import InterfaceCommonForm, ModuleCommonForm @@ -48,6 +50,7 @@ __all__ = ( 'ModuleBayForm', 'ModuleBayTemplateForm', 'ModuleTypeForm', + 'ModuleTypeProfileForm', 'PlatformForm', 'PopulateDeviceBayForm', 'PowerFeedForm', @@ -78,6 +81,7 @@ class RegionForm(NetBoxModelForm): required=False ) slug = SlugField() + comments = CommentField() fieldsets = ( FieldSet('parent', 'name', 'slug', 'description', 'tags'), @@ -86,7 +90,7 @@ class RegionForm(NetBoxModelForm): class Meta: model = Region fields = ( - 'parent', 'name', 'slug', 'description', 'tags', + 'parent', 'name', 'slug', 'description', 'tags', 'comments', ) @@ -97,6 +101,7 @@ class SiteGroupForm(NetBoxModelForm): required=False ) slug = SlugField() + comments = CommentField() fieldsets = ( FieldSet('parent', 'name', 'slug', 'description', 'tags'), @@ -105,7 +110,7 @@ class SiteGroupForm(NetBoxModelForm): class Meta: model = SiteGroup fields = ( - 'parent', 'name', 'slug', 'description', 'tags', + 'parent', 'name', 'slug', 'description', 'comments', 'tags', ) @@ -179,6 +184,7 @@ class LocationForm(TenancyForm, NetBoxModelForm): } ) slug = SlugField() + comments = CommentField() fieldsets = ( FieldSet('site', 'parent', 'name', 'slug', 'status', 'facility', 'description', 'tags', name=_('Location')), @@ -188,7 +194,8 @@ class LocationForm(TenancyForm, NetBoxModelForm): class Meta: model = Location fields = ( - 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant', 'facility', 'tags', + 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant', + 'facility', 'tags', 'comments', ) @@ -222,7 +229,7 @@ class RackTypeForm(NetBoxModelForm): FieldSet('manufacturer', 'model', 'slug', 'description', 'form_factor', 'tags', name=_('Rack Type')), FieldSet( 'width', 'u_height', - InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')), + InlineFields('outer_width', 'outer_height', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')), InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')), 'mounting_depth', name=_('Dimensions') ), @@ -233,8 +240,8 @@ class RackTypeForm(NetBoxModelForm): model = RackType fields = [ 'manufacturer', 'model', 'slug', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', - 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', - 'description', 'comments', 'tags', + 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', + 'weight_unit', 'description', 'comments', 'tags', ] @@ -279,8 +286,8 @@ class RackForm(TenancyForm, NetBoxModelForm): fields = [ 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial', 'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', - 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', - 'description', 'comments', 'tags', + 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight', + 'weight_unit', 'description', 'comments', 'tags', ] def __init__(self, *args, **kwargs): @@ -302,7 +309,8 @@ class RackForm(TenancyForm, NetBoxModelForm): *self.fieldsets, FieldSet( 'form_factor', 'width', 'starting_unit', 'u_height', - InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')), + InlineFields('outer_width', 'outer_height', 'outer_depth', 'outer_unit', + label=_('Outer Dimensions')), InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')), 'mounting_depth', 'desc_units', name=_('Dimensions') ), @@ -399,25 +407,104 @@ class DeviceTypeForm(NetBoxModelForm): } +class ModuleTypeProfileForm(NetBoxModelForm): + schema = JSONField( + label=_('Schema'), + required=False, + help_text=_("Enter a valid JSON schema to define supported attributes.") + ) + comments = CommentField() + + fieldsets = ( + FieldSet('name', 'description', 'schema', 'tags', name=_('Profile')), + ) + + class Meta: + model = ModuleTypeProfile + fields = [ + 'name', 'description', 'schema', 'comments', 'tags', + ] + + class ModuleTypeForm(NetBoxModelForm): + profile = forms.ModelChoiceField( + queryset=ModuleTypeProfile.objects.all(), + label=_('Profile'), + required=False, + widget=HTMXSelect() + ) manufacturer = DynamicModelChoiceField( label=_('Manufacturer'), queryset=Manufacturer.objects.all() ) comments = CommentField() - fieldsets = ( - FieldSet('manufacturer', 'model', 'part_number', 'description', 'tags', name=_('Module Type')), - FieldSet('airflow', 'weight', 'weight_unit', name=_('Chassis')) - ) + @property + def fieldsets(self): + return [ + FieldSet('manufacturer', 'model', 'part_number', 'description', 'tags', name=_('Module Type')), + FieldSet('airflow', 'weight', 'weight_unit', name=_('Hardware')), + FieldSet('profile', *self.attr_fields, name=_('Profile & Attributes')) + ] class Meta: model = ModuleType fields = [ - 'manufacturer', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description', + 'profile', 'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'comments', 'tags', ] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Track profile-specific attribute fields + self.attr_fields = [] + + # Retrieve assigned ModuleTypeProfile, if any + if not (profile_id := get_field_value(self, 'profile')): + return + if not (profile := ModuleTypeProfile.objects.filter(pk=profile_id).first()): + return + + # Extend form with fields for profile attributes + for attr, form_field in self._get_attr_form_fields(profile).items(): + field_name = f'attr_{attr}' + self.attr_fields.append(field_name) + self.fields[field_name] = form_field + if self.instance.attribute_data: + self.fields[field_name].initial = self.instance.attribute_data.get(attr) + + @staticmethod + def _get_attr_form_fields(profile): + """ + Return a dictionary mapping of attribute names to form fields, suitable for extending + the form per the selected ModuleTypeProfile. + """ + if not profile.schema: + return {} + + properties = profile.schema.get('properties', {}) + required_fields = profile.schema.get('required', []) + + attr_fields = {} + for name, options in properties.items(): + prop = JSONSchemaProperty(**options) + attr_fields[name] = prop.to_form_field(name, required=name in required_fields) + + return dict(sorted(attr_fields.items())) + + def _post_clean(self): + + # Compile attribute data from the individual form fields + if self.cleaned_data.get('profile'): + self.instance.attribute_data = { + name[5:]: self.cleaned_data[name] # Remove the attr_ prefix + for name in self.attr_fields + if self.cleaned_data.get(name) not in EMPTY_VALUES + } + + return super()._post_clean() + class DeviceRoleForm(NetBoxModelForm): config_template = DynamicModelChoiceField( @@ -426,17 +513,24 @@ class DeviceRoleForm(NetBoxModelForm): required=False ) slug = SlugField() + parent = DynamicModelChoiceField( + label=_('Parent'), + queryset=DeviceRole.objects.all(), + required=False, + ) + comments = CommentField() fieldsets = ( FieldSet( - 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags', name=_('Device Role') + 'name', 'slug', 'parent', 'color', 'vm_role', 'config_template', 'description', + 'tags', name=_('Device Role') ), ) class Meta: model = DeviceRole fields = [ - 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags', + 'name', 'slug', 'parent', 'color', 'vm_role', 'config_template', 'description', 'comments', 'tags', ] @@ -899,7 +993,7 @@ class ComponentTemplateForm(forms.ModelForm): class ModularComponentTemplateForm(ComponentTemplateForm): device_type = DynamicModelChoiceField( label=_('Device type'), - queryset=DeviceType.objects.all().all(), + queryset=DeviceType.objects.all(), required=False, context={ 'parent': 'manufacturer', @@ -914,6 +1008,16 @@ class ModularComponentTemplateForm(ComponentTemplateForm): } ) + fieldsets = ( + FieldSet( + TabbedGroups( + FieldSet('device_type', name=_('Device Type')), + FieldSet('module_type', name=_('Module Type')), + ), + 'name', 'label', 'type', 'description' + ), + ) + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -930,10 +1034,6 @@ class ModularComponentTemplateForm(ComponentTemplateForm): class ConsolePortTemplateForm(ModularComponentTemplateForm): - fieldsets = ( - FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'description'), - ) - class Meta: model = ConsolePortTemplate fields = [ @@ -942,10 +1042,6 @@ class ConsolePortTemplateForm(ModularComponentTemplateForm): class ConsoleServerPortTemplateForm(ModularComponentTemplateForm): - fieldsets = ( - FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'description'), - ) - class Meta: model = ConsoleServerPortTemplate fields = [ @@ -956,7 +1052,11 @@ class ConsoleServerPortTemplateForm(ModularComponentTemplateForm): class PowerPortTemplateForm(ModularComponentTemplateForm): fieldsets = ( FieldSet( - 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', + TabbedGroups( + FieldSet('device_type', name=_('Device Type')), + FieldSet('module_type', name=_('Module Type')), + ), + 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', ), ) @@ -978,7 +1078,13 @@ class PowerOutletTemplateForm(ModularComponentTemplateForm): ) fieldsets = ( - FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description'), + FieldSet( + TabbedGroups( + FieldSet('device_type', name=_('Device Type')), + FieldSet('module_type', name=_('Module Type')), + ), + 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', + ), ) class Meta: @@ -1001,7 +1107,11 @@ class InterfaceTemplateForm(ModularComponentTemplateForm): fieldsets = ( FieldSet( - 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'bridge', + TabbedGroups( + FieldSet('device_type', name=_('Device Type')), + FieldSet('module_type', name=_('Module Type')), + ), + 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'bridge', ), FieldSet('poe_mode', 'poe_type', name=_('PoE')), FieldSet('rf_role', name=_('Wireless')), @@ -1028,8 +1138,11 @@ class FrontPortTemplateForm(ModularComponentTemplateForm): fieldsets = ( FieldSet( - 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', - 'description', + TabbedGroups( + FieldSet('device_type', name=_('Device Type')), + FieldSet('module_type', name=_('Module Type')), + ), + 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', ), ) @@ -1043,7 +1156,13 @@ class FrontPortTemplateForm(ModularComponentTemplateForm): class RearPortTemplateForm(ModularComponentTemplateForm): fieldsets = ( - FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description'), + FieldSet( + TabbedGroups( + FieldSet('device_type', name=_('Device Type')), + FieldSet('module_type', name=_('Module Type')), + ), + 'name', 'label', 'type', 'color', 'positions', 'description', + ), ) class Meta: @@ -1055,7 +1174,13 @@ class RearPortTemplateForm(ModularComponentTemplateForm): class ModuleBayTemplateForm(ModularComponentTemplateForm): fieldsets = ( - FieldSet('device_type', 'module_type', 'name', 'label', 'position', 'description'), + FieldSet( + TabbedGroups( + FieldSet('device_type', name=_('Device Type')), + FieldSet('module_type', name=_('Module Type')), + ), + 'name', 'label', 'position', 'description', + ), ) class Meta: @@ -1308,7 +1433,7 @@ class PowerOutletForm(ModularDeviceComponentForm): fieldsets = ( FieldSet( - 'device', 'module', 'name', 'label', 'type', 'color', 'power_port', 'feed_leg', 'mark_connected', + 'device', 'module', 'name', 'label', 'type', 'status', 'color', 'power_port', 'feed_leg', 'mark_connected', 'description', 'tags', ), ) @@ -1316,7 +1441,7 @@ class PowerOutletForm(ModularDeviceComponentForm): class Meta: model = PowerOutlet fields = [ - 'device', 'module', 'name', 'label', 'type', 'color', 'power_port', 'feed_leg', 'mark_connected', + 'device', 'module', 'name', 'label', 'type', 'status', 'color', 'power_port', 'feed_leg', 'mark_connected', 'description', 'tags', ] @@ -1810,6 +1935,11 @@ class MACAddressForm(NetBoxModelForm): super().__init__(*args, **kwargs) + if instance and instance.assigned_object and instance.assigned_object.primary_mac_address: + if instance.assigned_object.primary_mac_address.pk == instance.pk: + self.fields['interface'].disabled = True + self.fields['vminterface'].disabled = True + def clean(self): super().clean() diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 6f6cd8f7c..bcf91c547 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -4,7 +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.rendering import FieldSet, TabbedGroups from utilities.forms.widgets import APISelect from . import model_forms @@ -55,19 +55,23 @@ class ComponentCreateForm(forms.Form): def clean(self): super().clean() - # Validate that all replication fields generate an equal number of values + # Validate that all replication fields generate an equal number of values (or a single value) if not (patterns := self.cleaned_data.get(self.replication_fields[0])): return - pattern_count = len(patterns) for field_name in self.replication_fields: value_count = len(self.cleaned_data[field_name]) - if self.cleaned_data[field_name] and value_count != pattern_count: - raise forms.ValidationError({ - field_name: _( - "The provided pattern specifies {value_count} values, but {pattern_count} are expected." - ).format(value_count=value_count, pattern_count=pattern_count) - }, code='label_pattern_mismatch') + if self.cleaned_data[field_name]: + if value_count == 1: + # If the field resolves to a single value (because no pattern was used), multiply it by the number + # of expected values. This allows us to reuse the same label when creating multiple components. + self.cleaned_data[field_name] = self.cleaned_data[field_name] * pattern_count + elif value_count != pattern_count: + raise forms.ValidationError({ + field_name: _( + "The provided pattern specifies {value_count} values, but {pattern_count} are expected." + ).format(value_count=value_count, pattern_count=pattern_count) + }, code='label_pattern_mismatch') # @@ -114,7 +118,13 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp # Override fieldsets from FrontPortTemplateForm to omit rear_port_position fieldsets = ( - FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'description'), + FieldSet( + TabbedGroups( + FieldSet('device_type', name=_('Device Type')), + FieldSet('module_type', name=_('Module Type')), + ), + 'name', 'label', 'type', 'color', 'rear_port', 'description', + ), ) class Meta(model_forms.FrontPortTemplateForm.Meta): @@ -153,6 +163,7 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp self.fields['rear_port'].choices = choices def clean(self): + super().clean() # Check that the number of FrontPortTemplates to be created matches the selected number of RearPortTemplate # positions @@ -302,6 +313,7 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm): self.fields['rear_port'].choices = choices def clean(self): + super().clean() # Check that the number of FrontPorts to be created matches the selected number of RearPort positions frontport_count = len(self.cleaned_data['name']) @@ -402,6 +414,7 @@ class VirtualChassisCreateForm(NetBoxModelForm): queryset=Device.objects.all(), required=False, query_params={ + 'virtual_chassis_id': 'null', 'site_id': '$site', 'rack_id': '$rack', } diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py index 821f91402..3f2cc3ef6 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -159,7 +159,7 @@ class ModuleBayTemplateImportForm(forms.ModelForm): class Meta: model = ModuleBayTemplate fields = [ - 'device_type', 'name', 'label', 'position', 'description', + 'device_type', 'module_type', 'name', 'label', 'position', 'description', ] diff --git a/netbox/dcim/graphql/enums.py b/netbox/dcim/graphql/enums.py new file mode 100644 index 000000000..5f888cfb5 --- /dev/null +++ b/netbox/dcim/graphql/enums.py @@ -0,0 +1,75 @@ +import strawberry + +from dcim.choices import * + +__all__ = ( + 'CableEndEnum', + 'CableLengthUnitEnum', + 'CableTypeEnum', + 'ConsolePortSpeedEnum', + 'ConsolePortTypeEnum', + 'DeviceAirflowEnum', + 'DeviceFaceEnum', + 'DeviceStatusEnum', + 'InterfaceDuplexEnum', + 'InterfaceModeEnum', + 'InterfacePoEModeEnum', + 'InterfacePoETypeEnum', + 'InterfaceTypeEnum', + 'InventoryItemStatusEnum', + 'LinkStatusEnum', + 'LocationStatusEnum', + 'ModuleAirflowEnum', + 'ModuleStatusEnum', + 'PortTypeEnum', + 'PowerFeedPhaseEnum', + 'PowerFeedStatusEnum', + 'PowerFeedSupplyEnum', + 'PowerFeedTypeEnum', + 'PowerOutletFeedLegEnum', + 'PowerOutletTypeEnum', + 'PowerPortTypeEnum', + 'RackAirflowEnum', + 'RackDimensionUnitEnum', + 'RackFormFactorEnum', + 'RackStatusEnum', + 'RackWidthEnum', + 'SiteStatusEnum', + 'SubdeviceRoleEnum', + 'VirtualDeviceContextStatusEnum', +) + +CableEndEnum = strawberry.enum(CableEndChoices.as_enum(prefix='side')) +CableLengthUnitEnum = strawberry.enum(CableLengthUnitChoices.as_enum(prefix='unit')) +CableTypeEnum = strawberry.enum(CableTypeChoices.as_enum(prefix='type')) +ConsolePortSpeedEnum = strawberry.enum(ConsolePortSpeedChoices.as_enum(prefix='speed')) +ConsolePortTypeEnum = strawberry.enum(ConsolePortTypeChoices.as_enum(prefix='type')) +DeviceAirflowEnum = strawberry.enum(DeviceAirflowChoices.as_enum(prefix='airflow')) +DeviceFaceEnum = strawberry.enum(DeviceFaceChoices.as_enum(prefix='face')) +DeviceStatusEnum = strawberry.enum(DeviceStatusChoices.as_enum(prefix='status')) +InterfaceDuplexEnum = strawberry.enum(InterfaceDuplexChoices.as_enum(prefix='duplex')) +InterfaceModeEnum = strawberry.enum(InterfaceModeChoices.as_enum(prefix='mode')) +InterfacePoEModeEnum = strawberry.enum(InterfacePoEModeChoices.as_enum(prefix='mode')) +InterfacePoETypeEnum = strawberry.enum(InterfacePoETypeChoices.as_enum()) +InterfaceTypeEnum = strawberry.enum(InterfaceTypeChoices.as_enum(prefix='type')) +InventoryItemStatusEnum = strawberry.enum(InventoryItemStatusChoices.as_enum(prefix='status')) +LinkStatusEnum = strawberry.enum(LinkStatusChoices.as_enum(prefix='status')) +LocationStatusEnum = strawberry.enum(LocationStatusChoices.as_enum(prefix='status')) +ModuleAirflowEnum = strawberry.enum(ModuleAirflowChoices.as_enum()) +ModuleStatusEnum = strawberry.enum(ModuleStatusChoices.as_enum(prefix='status')) +PortTypeEnum = strawberry.enum(PortTypeChoices.as_enum(prefix='type')) +PowerFeedPhaseEnum = strawberry.enum(PowerFeedPhaseChoices.as_enum(prefix='phase')) +PowerFeedStatusEnum = strawberry.enum(PowerFeedStatusChoices.as_enum(prefix='status')) +PowerFeedSupplyEnum = strawberry.enum(PowerFeedSupplyChoices.as_enum(prefix='supply')) +PowerFeedTypeEnum = strawberry.enum(PowerFeedTypeChoices.as_enum(prefix='type')) +PowerOutletFeedLegEnum = strawberry.enum(PowerOutletFeedLegChoices.as_enum(prefix='feed_leg')) +PowerOutletTypeEnum = strawberry.enum(PowerOutletTypeChoices.as_enum(prefix='type')) +PowerPortTypeEnum = strawberry.enum(PowerPortTypeChoices.as_enum(prefix='type')) +RackAirflowEnum = strawberry.enum(RackAirflowChoices.as_enum()) +RackDimensionUnitEnum = strawberry.enum(RackDimensionUnitChoices.as_enum(prefix='unit')) +RackFormFactorEnum = strawberry.enum(RackFormFactorChoices.as_enum(prefix='type')) +RackStatusEnum = strawberry.enum(RackStatusChoices.as_enum(prefix='status')) +RackWidthEnum = strawberry.enum(RackWidthChoices.as_enum(prefix='width')) +SiteStatusEnum = strawberry.enum(SiteStatusChoices.as_enum(prefix='status')) +SubdeviceRoleEnum = strawberry.enum(SubdeviceRoleChoices.as_enum(prefix='role')) +VirtualDeviceContextStatusEnum = strawberry.enum(VirtualDeviceContextStatusChoices.as_enum(prefix='status')) diff --git a/netbox/dcim/graphql/filter_mixins.py b/netbox/dcim/graphql/filter_mixins.py new file mode 100644 index 000000000..25379ad7f --- /dev/null +++ b/netbox/dcim/graphql/filter_mixins.py @@ -0,0 +1,148 @@ +from dataclasses import dataclass +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django +from strawberry import ID +from strawberry_django import FilterLookup + +from core.graphql.filter_mixins import BaseFilterMixin, ChangeLogFilterMixin +from core.graphql.filters import ContentTypeFilter +from netbox.graphql.filter_mixins import NetBoxModelFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin +from .enums import * + +if TYPE_CHECKING: + from netbox.graphql.filter_lookups import IntegerLookup + from extras.graphql.filters import ConfigTemplateFilter + from ipam.graphql.filters import VLANFilter, VLANTranslationPolicyFilter + from .filters import * + +__all__ = ( + 'CabledObjectModelFilterMixin', + 'ComponentModelFilterMixin', + 'ComponentTemplateFilterMixin', + 'InterfaceBaseFilterMixin', + 'ModularComponentModelFilterMixin', + 'ModularComponentTemplateFilterMixin', + 'RackBaseFilterMixin', + 'RenderConfigFilterMixin', + 'ScopedFilterMixin', +) + + +@dataclass +class ScopedFilterMixin(BaseFilterMixin): + scope_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + scope_id: ID | None = strawberry_django.filter_field() + + +@dataclass +class ComponentModelFilterMixin(NetBoxModelFilterMixin): + device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + device_id: ID | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() + label: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + + +@dataclass +class ModularComponentModelFilterMixin(ComponentModelFilterMixin): + module: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + module_id: ID | None = strawberry_django.filter_field() + inventory_items: Annotated['InventoryItemFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + + +@dataclass +class CabledObjectModelFilterMixin(BaseFilterMixin): + cable: Annotated['CableFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + cable_id: ID | None = strawberry_django.filter_field() + cable_end: CableEndEnum | None = strawberry_django.filter_field() + mark_connected: FilterLookup[bool] | None = strawberry_django.filter_field() + + +@dataclass +class ComponentTemplateFilterMixin(ChangeLogFilterMixin): + device_type: Annotated['DeviceTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + device_type_id: ID | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() + label: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + + +@dataclass +class ModularComponentTemplateFilterMixin(ComponentTemplateFilterMixin): + module_type: Annotated['ModuleTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + + +@dataclass +class RenderConfigFilterMixin(BaseFilterMixin): + config_template: Annotated['ConfigTemplateFilter', strawberry.lazy('extras.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + config_template_id: ID | None = strawberry_django.filter_field() + + +@dataclass +class InterfaceBaseFilterMixin(BaseFilterMixin): + enabled: FilterLookup[bool] | None = strawberry_django.filter_field() + mtu: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + mode: InterfaceModeEnum | None = strawberry_django.filter_field() + bridge: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + bridge_id: ID | None = strawberry_django.filter_field() + untagged_vlan: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + tagged_vlans: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + qinq_svlan: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + vlan_translation_policy: Annotated['VLANTranslationPolicyFilter', strawberry.lazy('ipam.graphql.filters')] | None \ + = strawberry_django.filter_field() + primary_mac_address: Annotated['MACAddressFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + primary_mac_address_id: ID | None = strawberry_django.filter_field() + + +@dataclass +class RackBaseFilterMixin(WeightFilterMixin, PrimaryModelFilterMixin): + width: Annotated['RackWidthEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + u_height: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + starting_unit: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + desc_units: FilterLookup[bool] | None = strawberry_django.filter_field() + outer_width: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + outer_height: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + outer_depth: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + outer_unit: Annotated['RackDimensionUnitEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + mounting_depth: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + max_weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py index 94f2c6d38..77e7a53b9 100644 --- a/netbox/dcim/graphql/filters.py +++ b/netbox/dcim/graphql/filters.py @@ -1,7 +1,46 @@ -import strawberry_django +from typing import Annotated, TYPE_CHECKING -from dcim import filtersets, models -from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin +import strawberry +import strawberry_django +from strawberry.scalars import ID +from strawberry_django import FilterLookup + +from core.graphql.filter_mixins import ChangeLogFilterMixin +from dcim import models +from extras.graphql.filter_mixins import ConfigContextFilterMixin +from netbox.graphql.filter_mixins import ( + PrimaryModelFilterMixin, + OrganizationalModelFilterMixin, + NestedGroupModelFilterMixin, + ImageAttachmentFilterMixin, + WeightFilterMixin, +) +from tenancy.graphql.filter_mixins import TenancyFilterMixin, ContactFilterMixin +from .filter_mixins import ( + CabledObjectModelFilterMixin, + ComponentModelFilterMixin, + ComponentTemplateFilterMixin, + InterfaceBaseFilterMixin, + ModularComponentModelFilterMixin, + ModularComponentTemplateFilterMixin, + RackBaseFilterMixin, + RenderConfigFilterMixin, +) + +if TYPE_CHECKING: + from core.graphql.filters import ContentTypeFilter + from extras.graphql.filters import ConfigTemplateFilter, ImageAttachmentFilter + from ipam.graphql.filters import ( + ASNFilter, FHRPGroupAssignmentFilter, IPAddressFilter, PrefixFilter, VLANGroupFilter, VRFFilter, + ) + from netbox.graphql.enums import ColorEnum + from netbox.graphql.filter_lookups import FloatLookup, IntegerArrayLookup, IntegerLookup, TreeNodeFilter + from users.graphql.filters import UserFilter + from virtualization.graphql.filters import ClusterFilter + from vpn.graphql.filters import L2VPNFilter, TunnelTerminationFilter + from wireless.graphql.enums import WirelessChannelEnum, WirelessRoleEnum + from wireless.graphql.filters import WirelessLANFilter, WirelessLinkFilter + from .enums import * __all__ = ( 'CableFilter', @@ -13,7 +52,6 @@ __all__ = ( 'DeviceFilter', 'DeviceBayFilter', 'DeviceBayTemplateFilter', - 'InventoryItemTemplateFilter', 'DeviceRoleFilter', 'DeviceTypeFilter', 'FrontPortFilter', @@ -22,6 +60,7 @@ __all__ = ( 'InterfaceTemplateFilter', 'InventoryItemFilter', 'InventoryItemRoleFilter', + 'InventoryItemTemplateFilter', 'LocationFilter', 'MACAddressFilter', 'ManufacturerFilter', @@ -29,6 +68,7 @@ __all__ = ( 'ModuleBayFilter', 'ModuleBayTemplateFilter', 'ModuleTypeFilter', + 'ModuleTypeProfileFilter', 'PlatformFilter', 'PowerFeedFilter', 'PowerOutletFilter', @@ -51,258 +91,872 @@ __all__ = ( @strawberry_django.filter(models.Cable, lookups=True) -@autotype_decorator(filtersets.CableFilterSet) -class CableFilter(BaseFilterMixin): - pass +class CableFilter(PrimaryModelFilterMixin, TenancyFilterMixin): + type: Annotated['CableTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + status: Annotated['LinkStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + label: FilterLookup[str] | None = strawberry_django.filter_field() + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + length: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + length_unit: Annotated['CableLengthUnitEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + terminations: Annotated['CableTerminationFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.CableTermination, lookups=True) -@autotype_decorator(filtersets.CableTerminationFilterSet) -class CableTerminationFilter(BaseFilterMixin): - pass +class CableTerminationFilter(ChangeLogFilterMixin): + cable: Annotated['CableFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + cable_id: ID | None = strawberry_django.filter_field() + cable_end: Annotated['CableEndEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + termination_type: Annotated['CableTerminationFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + termination_id: ID | None = strawberry_django.filter_field() @strawberry_django.filter(models.ConsolePort, lookups=True) -@autotype_decorator(filtersets.ConsolePortFilterSet) -class ConsolePortFilter(BaseFilterMixin): - pass +class ConsolePortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): + type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + speed: Annotated['ConsolePortSpeedEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.ConsolePortTemplate, lookups=True) -@autotype_decorator(filtersets.ConsolePortTemplateFilterSet) -class ConsolePortTemplateFilter(BaseFilterMixin): - pass +class ConsolePortTemplateFilter(ModularComponentTemplateFilterMixin): + type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.ConsoleServerPort, lookups=True) -@autotype_decorator(filtersets.ConsoleServerPortFilterSet) -class ConsoleServerPortFilter(BaseFilterMixin): - pass +class ConsoleServerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): + type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + speed: Annotated['ConsolePortSpeedEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.ConsoleServerPortTemplate, lookups=True) -@autotype_decorator(filtersets.ConsoleServerPortTemplateFilterSet) -class ConsoleServerPortTemplateFilter(BaseFilterMixin): - pass +class ConsoleServerPortTemplateFilter(ModularComponentTemplateFilterMixin): + type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.Device, lookups=True) -@autotype_decorator(filtersets.DeviceFilterSet) -class DeviceFilter(BaseFilterMixin): - pass +class DeviceFilter( + ContactFilterMixin, + TenancyFilterMixin, + ImageAttachmentFilterMixin, + RenderConfigFilterMixin, + ConfigContextFilterMixin, + PrimaryModelFilterMixin, +): + device_type: Annotated['DeviceTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + device_type_id: ID | None = strawberry_django.filter_field() + role: Annotated['DeviceRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + role_id: ID | None = strawberry_django.filter_field() + platform: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + name: FilterLookup[str] | None = strawberry_django.filter_field() + serial: FilterLookup[str] | None = strawberry_django.filter_field() + asset_tag: FilterLookup[str] | None = strawberry_django.filter_field() + site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + site_id: ID | None = strawberry_django.filter_field() + location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + rack_id: ID | None = strawberry_django.filter_field() + position: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + face: Annotated['DeviceFaceEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + status: Annotated['DeviceStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + airflow: Annotated['DeviceAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + primary_ip4: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + primary_ip4_id: ID | None = strawberry_django.filter_field() + primary_ip6: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + primary_ip6_id: ID | None = strawberry_django.filter_field() + oob_ip: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + oob_ip_id: ID | None = strawberry_django.filter_field() + cluster: Annotated['ClusterFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + cluster_id: ID | None = strawberry_django.filter_field() + virtual_chassis: Annotated['VirtualChassisFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + virtual_chassis_id: ID | None = strawberry_django.filter_field() + vc_position: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + vc_priority: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + latitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + longitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + console_ports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + console_server_ports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + power_outlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + power_ports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + interfaces: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + front_ports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + rear_ports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + device_bays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + module_bays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + modules: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + console_port_count: FilterLookup[int] | None = strawberry_django.filter_field() + console_server_port_count: FilterLookup[int] | None = strawberry_django.filter_field() + power_port_count: FilterLookup[int] | None = strawberry_django.filter_field() + power_outlet_count: FilterLookup[int] | None = strawberry_django.filter_field() + interface_count: FilterLookup[int] | None = strawberry_django.filter_field() + front_port_count: FilterLookup[int] | None = strawberry_django.filter_field() + rear_port_count: FilterLookup[int] | None = strawberry_django.filter_field() + device_bay_count: FilterLookup[int] | None = strawberry_django.filter_field() + module_bay_count: FilterLookup[int] | None = strawberry_django.filter_field() + inventory_item_count: FilterLookup[int] | None = strawberry_django.filter_field() @strawberry_django.filter(models.DeviceBay, lookups=True) -@autotype_decorator(filtersets.DeviceBayFilterSet) -class DeviceBayFilter(BaseFilterMixin): - pass +class DeviceBayFilter(ComponentModelFilterMixin): + installed_device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + installed_device_id: ID | None = strawberry_django.filter_field() @strawberry_django.filter(models.DeviceBayTemplate, lookups=True) -@autotype_decorator(filtersets.DeviceBayTemplateFilterSet) -class DeviceBayTemplateFilter(BaseFilterMixin): +class DeviceBayTemplateFilter(ComponentTemplateFilterMixin): pass @strawberry_django.filter(models.InventoryItemTemplate, lookups=True) -@autotype_decorator(filtersets.InventoryItemTemplateFilterSet) -class InventoryItemTemplateFilter(BaseFilterMixin): - pass +class InventoryItemTemplateFilter(ComponentTemplateFilterMixin): + parent: Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + component_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + component_id: ID | None = strawberry_django.filter_field() + role: Annotated['InventoryItemRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + role_id: ID | None = strawberry_django.filter_field() + manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + manufacturer_id: ID | None = strawberry_django.filter_field() + part_id: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.DeviceRole, lookups=True) -@autotype_decorator(filtersets.DeviceRoleFilterSet) -class DeviceRoleFilter(BaseFilterMixin): - pass +class DeviceRoleFilter(OrganizationalModelFilterMixin, RenderConfigFilterMixin): + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + vm_role: FilterLookup[bool] | None = strawberry_django.filter_field() @strawberry_django.filter(models.DeviceType, lookups=True) -@autotype_decorator(filtersets.DeviceTypeFilterSet) -class DeviceTypeFilter(BaseFilterMixin): - pass +class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin): + manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + manufacturer_id: ID | None = strawberry_django.filter_field() + model: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() + default_platform: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + default_platform_id: ID | None = strawberry_django.filter_field() + part_number: FilterLookup[str] | None = strawberry_django.filter_field() + u_height: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + exclude_from_utilization: FilterLookup[bool] | None = strawberry_django.filter_field() + is_full_depth: FilterLookup[bool] | None = strawberry_django.filter_field() + subdevice_role: Annotated['SubdeviceRoleEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + airflow: Annotated['DeviceAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + front_image: Annotated['ImageAttachmentFilter', strawberry.lazy('extras.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + rear_image: Annotated['ImageAttachmentFilter', strawberry.lazy('extras.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + console_port_templates: ( + Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None + ) = strawberry_django.filter_field() + console_server_port_templates: ( + Annotated['ConsoleServerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None + ) = strawberry_django.filter_field() + power_port_templates: ( + Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None + ) = strawberry_django.filter_field() + power_outlet_templates: ( + Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None + ) = strawberry_django.filter_field() + interface_templates: ( + Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None + ) = strawberry_django.filter_field() + front_port_templates: ( + Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None + ) = strawberry_django.filter_field() + rear_port_templates: ( + Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None + ) = strawberry_django.filter_field() + device_bay_templates: ( + Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None + ) = strawberry_django.filter_field() + module_bay_templates: ( + Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None + ) = strawberry_django.filter_field() + inventory_item_templates: ( + Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None + ) = strawberry_django.filter_field() + console_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + console_server_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + power_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + power_outlet_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + interface_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + front_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + rear_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + device_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + module_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + inventory_item_template_count: FilterLookup[int] | None = strawberry_django.filter_field() @strawberry_django.filter(models.FrontPort, lookups=True) -@autotype_decorator(filtersets.FrontPortFilterSet) -class FrontPortFilter(BaseFilterMixin): - pass +class FrontPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): + type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + rear_port: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + rear_port_id: ID | None = strawberry_django.filter_field() + rear_port_position: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.FrontPortTemplate, lookups=True) -@autotype_decorator(filtersets.FrontPortTemplateFilterSet) -class FrontPortTemplateFilter(BaseFilterMixin): - pass +class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin): + type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + rear_port: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + rear_port_id: ID | None = strawberry_django.filter_field() + rear_port_position: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.MACAddress, lookups=True) -@autotype_decorator(filtersets.MACAddressFilterSet) -class MACAddressFilter(BaseFilterMixin): - pass +class MACAddressFilter(PrimaryModelFilterMixin): + mac_address: FilterLookup[str] | None = strawberry_django.filter_field() + assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + assigned_object_id: ID | None = strawberry_django.filter_field() @strawberry_django.filter(models.Interface, lookups=True) -@autotype_decorator(filtersets.InterfaceFilterSet) -class InterfaceFilter(BaseFilterMixin): - pass +class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin, CabledObjectModelFilterMixin): + vcdcs: Annotated['VirtualDeviceContextFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + lag: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + lag_id: ID | None = strawberry_django.filter_field() + type: Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + mgmt_only: FilterLookup[bool] | None = strawberry_django.filter_field() + speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + duplex: Annotated['InterfaceDuplexEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + wwn: FilterLookup[str] | None = strawberry_django.filter_field() + parent: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + parent_id: ID | None = strawberry_django.filter_field() + rf_role: Annotated['WirelessRoleEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + rf_channel: Annotated['WirelessChannelEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + rf_channel_frequency: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + rf_channel_width: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + tx_power: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + poe_mode: Annotated['InterfacePoEModeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + poe_type: Annotated['InterfacePoETypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + wireless_link: Annotated['WirelessLinkFilter', strawberry.lazy('wireless.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + wireless_link_id: ID | None = strawberry_django.filter_field() + wireless_lans: Annotated['WirelessLANFilter', strawberry.lazy('wireless.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() + vrf_id: ID | None = strawberry_django.filter_field() + ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + mac_addresses: Annotated['MACAddressFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + fhrp_group_assignments: Annotated['FHRPGroupAssignmentFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + tunnel_terminations: Annotated['TunnelTerminationFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + l2vpn_terminations: Annotated['L2VPNFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.InterfaceTemplate, lookups=True) -@autotype_decorator(filtersets.InterfaceTemplateFilterSet) -class InterfaceTemplateFilter(BaseFilterMixin): - pass +class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin): + type: Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + enabled: FilterLookup[bool] | None = strawberry_django.filter_field() + mgmt_only: FilterLookup[bool] | None = strawberry_django.filter_field() + bridge: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + bridge_id: ID | None = strawberry_django.filter_field() + poe_mode: Annotated['InterfacePoEModeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + poe_type: Annotated['InterfacePoETypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + rf_role: Annotated['WirelessRoleEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.InventoryItem, lookups=True) -@autotype_decorator(filtersets.InventoryItemFilterSet) -class InventoryItemFilter(BaseFilterMixin): - pass +class InventoryItemFilter(ComponentModelFilterMixin): + parent: Annotated['InventoryItemFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + parent_id: ID | None = strawberry_django.filter_field() + component_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + component_id: ID | None = strawberry_django.filter_field() + status: Annotated['InventoryItemStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + role: Annotated['InventoryItemRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + role_id: ID | None = strawberry_django.filter_field() + manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + manufacturer_id: ID | None = strawberry_django.filter_field() + part_id: FilterLookup[str] | None = strawberry_django.filter_field() + serial: FilterLookup[str] | None = strawberry_django.filter_field() + asset_tag: FilterLookup[str] | None = strawberry_django.filter_field() + discovered: FilterLookup[bool] | None = strawberry_django.filter_field() @strawberry_django.filter(models.InventoryItemRole, lookups=True) -@autotype_decorator(filtersets.InventoryItemRoleFilterSet) -class InventoryItemRoleFilter(BaseFilterMixin): - pass +class InventoryItemRoleFilter(OrganizationalModelFilterMixin): + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() @strawberry_django.filter(models.Location, lookups=True) -@autotype_decorator(filtersets.LocationFilterSet) -class LocationFilter(BaseFilterMixin): - pass +class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, NestedGroupModelFilterMixin): + site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + site_id: ID | None = strawberry_django.filter_field() + status: Annotated['LocationStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + facility: FilterLookup[str] | None = strawberry_django.filter_field() + prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.Manufacturer, lookups=True) -@autotype_decorator(filtersets.ManufacturerFilterSet) -class ManufacturerFilter(BaseFilterMixin): +class ManufacturerFilter(ContactFilterMixin, OrganizationalModelFilterMixin): pass @strawberry_django.filter(models.Module, lookups=True) -@autotype_decorator(filtersets.ModuleFilterSet) -class ModuleFilter(BaseFilterMixin): - pass +class ModuleFilter(PrimaryModelFilterMixin, ConfigContextFilterMixin): + device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + device_id: ID | None = strawberry_django.filter_field() + module_bay: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + module_bay_id: ID | None = strawberry_django.filter_field() + module_type: Annotated['ModuleTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + module_type_id: ID | None = strawberry_django.filter_field() + status: Annotated['ModuleStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + serial: FilterLookup[str] | None = strawberry_django.filter_field() + asset_tag: FilterLookup[str] | None = strawberry_django.filter_field() + console_ports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + console_server_ports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + power_outlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + power_ports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + interfaces: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + front_ports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + rear_ports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + device_bays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + module_bays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + modules: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.ModuleBay, lookups=True) -@autotype_decorator(filtersets.ModuleBayFilterSet) -class ModuleBayFilter(BaseFilterMixin): - pass +class ModuleBayFilter(ModularComponentModelFilterMixin): + parent: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + parent_id: ID | None = strawberry_django.filter_field() + position: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.ModuleBayTemplate, lookups=True) -@autotype_decorator(filtersets.ModuleBayTemplateFilterSet) -class ModuleBayTemplateFilter(BaseFilterMixin): - pass +class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin): + position: FilterLookup[str] | None = strawberry_django.filter_field() + + +@strawberry_django.filter(models.ModuleTypeProfile, lookups=True) +class ModuleTypeProfileFilter(PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.ModuleType, lookups=True) -@autotype_decorator(filtersets.ModuleTypeFilterSet) -class ModuleTypeFilter(BaseFilterMixin): - pass +class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin): + manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + manufacturer_id: ID | None = strawberry_django.filter_field() + profile: Annotated['ModuleTypeProfileFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + profile_id: ID | None = strawberry_django.filter_field() + model: FilterLookup[str] | None = strawberry_django.filter_field() + part_number: FilterLookup[str] | None = strawberry_django.filter_field() + airflow: Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + console_port_templates: ( + Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None + ) = strawberry_django.filter_field() + console_server_port_templates: ( + Annotated['ConsoleServerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None + ) = strawberry_django.filter_field() + power_port_templates: ( + Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None + ) = strawberry_django.filter_field() + power_outlet_templates: ( + Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None + ) = strawberry_django.filter_field() + interface_templates: ( + Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None + ) = strawberry_django.filter_field() + front_port_templates: ( + Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None + ) = strawberry_django.filter_field() + rear_port_templates: ( + Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None + ) = strawberry_django.filter_field() + device_bay_templates: ( + Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None + ) = strawberry_django.filter_field() + module_bay_templates: ( + Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None + ) = strawberry_django.filter_field() + inventory_item_templates: ( + Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None + ) = strawberry_django.filter_field() @strawberry_django.filter(models.Platform, lookups=True) -@autotype_decorator(filtersets.PlatformFilterSet) -class PlatformFilter(BaseFilterMixin): - pass +class PlatformFilter(OrganizationalModelFilterMixin): + manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + manufacturer_id: ID | None = strawberry_django.filter_field() + config_template: Annotated['ConfigTemplateFilter', strawberry.lazy('extras.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + config_template_id: ID | None = strawberry_django.filter_field() @strawberry_django.filter(models.PowerFeed, lookups=True) -@autotype_decorator(filtersets.PowerFeedFilterSet) -class PowerFeedFilter(BaseFilterMixin): - pass +class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin): + power_panel: Annotated['PowerPanelFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + power_panel_id: ID | None = strawberry_django.filter_field() + rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + rack_id: ID | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() + status: Annotated['PowerFeedStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + type: Annotated['PowerFeedTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + supply: Annotated['PowerFeedSupplyEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + phase: Annotated['PowerFeedPhaseEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + voltage: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + amperage: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + max_utilization: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + available_power: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.PowerOutlet, lookups=True) -@autotype_decorator(filtersets.PowerOutletFilterSet) -class PowerOutletFilter(BaseFilterMixin): - pass +class PowerOutletFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): + type: Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + power_port: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + power_port_id: ID | None = strawberry_django.filter_field() + feed_leg: Annotated['PowerOutletFeedLegEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() @strawberry_django.filter(models.PowerOutletTemplate, lookups=True) -@autotype_decorator(filtersets.PowerOutletTemplateFilterSet) -class PowerOutletTemplateFilter(BaseFilterMixin): - pass +class PowerOutletTemplateFilter(ModularComponentModelFilterMixin): + type: Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + power_port: Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + power_port_id: ID | None = strawberry_django.filter_field() + feed_leg: Annotated['PowerOutletFeedLegEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.PowerPanel, lookups=True) -@autotype_decorator(filtersets.PowerPanelFilterSet) -class PowerPanelFilter(BaseFilterMixin): - pass +class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryModelFilterMixin): + site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + site_id: ID | None = strawberry_django.filter_field() + location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + name: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.PowerPort, lookups=True) -@autotype_decorator(filtersets.PowerPortFilterSet) -class PowerPortFilter(BaseFilterMixin): - pass +class PowerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): + type: Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + maximum_draw: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + allocated_draw: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.PowerPortTemplate, lookups=True) -@autotype_decorator(filtersets.PowerPortTemplateFilterSet) -class PowerPortTemplateFilter(BaseFilterMixin): - pass +class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin): + type: Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + maximum_draw: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + allocated_draw: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.RackType, lookups=True) -@autotype_decorator(filtersets.RackTypeFilterSet) -class RackTypeFilter(BaseFilterMixin): - pass +class RackTypeFilter(RackBaseFilterMixin): + form_factor: Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + manufacturer_id: ID | None = strawberry_django.filter_field() + model: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.Rack, lookups=True) -@autotype_decorator(filtersets.RackFilterSet) -class RackFilter(BaseFilterMixin): - pass +class RackFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, RackBaseFilterMixin): + form_factor: Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + rack_type: Annotated['RackTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + rack_type_id: ID | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() + facility_id: FilterLookup[str] | None = strawberry_django.filter_field() + site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + site_id: ID | None = strawberry_django.filter_field() + location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + status: Annotated['RackStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + role: Annotated['RackRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + role_id: ID | None = strawberry_django.filter_field() + serial: FilterLookup[str] | None = strawberry_django.filter_field() + asset_tag: FilterLookup[str] | None = strawberry_django.filter_field() + airflow: Annotated['RackAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.RackReservation, lookups=True) -@autotype_decorator(filtersets.RackReservationFilterSet) -class RackReservationFilter(BaseFilterMixin): - pass +class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilterMixin): + rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + rack_id: ID | None = strawberry_django.filter_field() + units: Annotated['IntegerArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() + user_id: ID | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.RackRole, lookups=True) -@autotype_decorator(filtersets.RackRoleFilterSet) -class RackRoleFilter(BaseFilterMixin): - pass +class RackRoleFilter(OrganizationalModelFilterMixin): + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() @strawberry_django.filter(models.RearPort, lookups=True) -@autotype_decorator(filtersets.RearPortFilterSet) -class RearPortFilter(BaseFilterMixin): - pass +class RearPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): + type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + positions: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.RearPortTemplate, lookups=True) -@autotype_decorator(filtersets.RearPortTemplateFilterSet) -class RearPortTemplateFilter(BaseFilterMixin): - pass +class RearPortTemplateFilter(ModularComponentTemplateFilterMixin): + type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + positions: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.Region, lookups=True) -@autotype_decorator(filtersets.RegionFilterSet) -class RegionFilter(BaseFilterMixin): - pass +class RegionFilter(ContactFilterMixin, NestedGroupModelFilterMixin): + prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.Site, lookups=True) -@autotype_decorator(filtersets.SiteFilterSet) -class SiteFilter(BaseFilterMixin): - pass +class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() + status: Annotated['SiteStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + region: Annotated['RegionFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + region_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + group: Annotated['SiteGroupFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + facility: FilterLookup[str] | None = strawberry_django.filter_field() + asns: Annotated['ASNFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() + time_zone: FilterLookup[str] | None = strawberry_django.filter_field() + physical_address: FilterLookup[str] | None = strawberry_django.filter_field() + shipping_address: FilterLookup[str] | None = strawberry_django.filter_field() + latitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + longitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.SiteGroup, lookups=True) -@autotype_decorator(filtersets.SiteGroupFilterSet) -class SiteGroupFilter(BaseFilterMixin): - pass +class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilterMixin): + prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.VirtualChassis, lookups=True) -@autotype_decorator(filtersets.VirtualChassisFilterSet) -class VirtualChassisFilter(BaseFilterMixin): - pass +class VirtualChassisFilter(PrimaryModelFilterMixin): + master: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + master_id: ID | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() + domain: FilterLookup[str] | None = strawberry_django.filter_field() + members: ( + Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None + ) = strawberry_django.filter_field() + member_count: FilterLookup[int] | None = strawberry_django.filter_field() @strawberry_django.filter(models.VirtualDeviceContext, lookups=True) -@autotype_decorator(filtersets.VirtualDeviceContextFilterSet) -class VirtualDeviceContextFilter(BaseFilterMixin): - pass +class VirtualDeviceContextFilter(TenancyFilterMixin, PrimaryModelFilterMixin): + device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + device_id: ID | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() + status: Annotated['VirtualDeviceContextStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + identifier: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + primary_ip4: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + primary_ip4_id: ID | None = strawberry_django.filter_field() + primary_ip6: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + primary_ip6_id: ID | None = strawberry_django.filter_field() + comments: FilterLookup[str] | None = strawberry_django.filter_field() + interfaces: ( + Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None + ) = strawberry_django.filter_field() diff --git a/netbox/dcim/graphql/mixins.py b/netbox/dcim/graphql/mixins.py index 2e5ab7ea7..de8691889 100644 --- a/netbox/dcim/graphql/mixins.py +++ b/netbox/dcim/graphql/mixins.py @@ -30,6 +30,7 @@ class PathEndpointMixin: connected_endpoints: List[Annotated[Union[ Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')], # noqa: F821 + Annotated["VirtualCircuitTerminationType", strawberry.lazy('circuits.graphql.types')], # noqa: F821 Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821 Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821 Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821 diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index 011a2b58b..1b0661bc2 100644 --- a/netbox/dcim/graphql/schema.py +++ b/netbox/dcim/graphql/schema.py @@ -77,6 +77,9 @@ class DCIMQuery: module_bay_template: ModuleBayTemplateType = strawberry_django.field() module_bay_template_list: List[ModuleBayTemplateType] = strawberry_django.field() + module_type_profile: ModuleTypeProfileType = strawberry_django.field() + module_type_profile_list: List[ModuleTypeProfileType] = strawberry_django.field() + module_type: ModuleTypeType = strawberry_django.field() module_type_list: List[ModuleTypeType] = strawberry_django.field() diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 8d992176a..7f801c01b 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -1,4 +1,4 @@ -from typing import Annotated, List, Union +from typing import Annotated, List, TYPE_CHECKING, Union import strawberry import strawberry_django @@ -6,7 +6,11 @@ import strawberry_django from core.graphql.mixins import ChangelogMixin from dcim import models from extras.graphql.mixins import ( - ConfigContextMixin, ContactsMixin, CustomFieldsMixin, ImageAttachmentsMixin, TagsMixin, + ConfigContextMixin, + ContactsMixin, + CustomFieldsMixin, + ImageAttachmentsMixin, + TagsMixin, ) from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin from netbox.graphql.scalars import BigInt @@ -14,6 +18,23 @@ from netbox.graphql.types import BaseObjectType, NetBoxObjectType, Organizationa from .filters import * from .mixins import CabledObjectMixin, PathEndpointMixin +if TYPE_CHECKING: + from circuits.graphql.types import CircuitTerminationType + from extras.graphql.types import ConfigTemplateType + from ipam.graphql.types import ( + ASNType, + IPAddressType, + PrefixType, + ServiceType, + VLANTranslationPolicyType, + VLANType, + VRFType, + ) + from tenancy.graphql.types import TenantType + from users.graphql.types import UserType + from virtualization.graphql.types import ClusterType, VMInterfaceType, VirtualMachineType + from wireless.graphql.types import WirelessLANType, WirelessLinkType + __all__ = ( 'CableType', 'ComponentType', @@ -40,6 +61,7 @@ __all__ = ( 'ModuleType', 'ModuleBayType', 'ModuleBayTemplateType', + 'ModuleTypeProfileType', 'ModuleTypeType', 'PlatformType', 'PowerFeedType', @@ -111,8 +133,9 @@ class ModularComponentTemplateType(ComponentTemplateType): @strawberry_django.type( models.CableTermination, - exclude=('termination_type', 'termination_id', '_device', '_rack', '_location', '_site'), - filters=CableTerminationFilter + exclude=['termination_type', 'termination_id', '_device', '_rack', '_location', '_site'], + filters=CableTerminationFilter, + pagination=True ) class CableTerminationType(NetBoxObjectType): cable: Annotated["CableType", strawberry.lazy('dcim.graphql.types')] | None @@ -132,7 +155,8 @@ class CableTerminationType(NetBoxObjectType): @strawberry_django.type( models.Cable, fields='__all__', - filters=CableFilter + filters=CableFilter, + pagination=True ) class CableType(NetBoxObjectType): color: str @@ -167,8 +191,9 @@ class CableType(NetBoxObjectType): @strawberry_django.type( models.ConsolePort, - exclude=('_path',), - filters=ConsolePortFilter + exclude=['_path'], + filters=ConsolePortFilter, + pagination=True ) class ConsolePortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): pass @@ -177,7 +202,8 @@ class ConsolePortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin @strawberry_django.type( models.ConsolePortTemplate, fields='__all__', - filters=ConsolePortTemplateFilter + filters=ConsolePortTemplateFilter, + pagination=True ) class ConsolePortTemplateType(ModularComponentTemplateType): pass @@ -185,8 +211,9 @@ class ConsolePortTemplateType(ModularComponentTemplateType): @strawberry_django.type( models.ConsoleServerPort, - exclude=('_path',), - filters=ConsoleServerPortFilter + exclude=['_path'], + filters=ConsoleServerPortFilter, + pagination=True ) class ConsoleServerPortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): pass @@ -195,7 +222,8 @@ class ConsoleServerPortType(ModularComponentType, CabledObjectMixin, PathEndpoin @strawberry_django.type( models.ConsoleServerPortTemplate, fields='__all__', - filters=ConsoleServerPortTemplateFilter + filters=ConsoleServerPortTemplateFilter, + pagination=True ) class ConsoleServerPortTemplateType(ModularComponentTemplateType): pass @@ -204,7 +232,8 @@ class ConsoleServerPortTemplateType(ModularComponentTemplateType): @strawberry_django.type( models.Device, fields='__all__', - filters=DeviceFilter + filters=DeviceFilter, + pagination=True ) class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType): console_port_count: BigInt @@ -259,7 +288,8 @@ class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, NetBo @strawberry_django.type( models.DeviceBay, fields='__all__', - filters=DeviceBayFilter + filters=DeviceBayFilter, + pagination=True ) class DeviceBayType(ComponentType): installed_device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None @@ -268,7 +298,8 @@ class DeviceBayType(ComponentType): @strawberry_django.type( models.DeviceBayTemplate, fields='__all__', - filters=DeviceBayTemplateFilter + filters=DeviceBayTemplateFilter, + pagination=True ) class DeviceBayTemplateType(ComponentTemplateType): pass @@ -276,8 +307,9 @@ class DeviceBayTemplateType(ComponentTemplateType): @strawberry_django.type( models.InventoryItemTemplate, - exclude=('component_type', 'component_id', 'parent'), - filters=InventoryItemTemplateFilter + exclude=['component_type', 'component_id', 'parent'], + filters=InventoryItemTemplateFilter, + pagination=True ) class InventoryItemTemplateType(ComponentTemplateType): role: Annotated["InventoryItemRoleType", strawberry.lazy('dcim.graphql.types')] | None @@ -303,9 +335,12 @@ class InventoryItemTemplateType(ComponentTemplateType): @strawberry_django.type( models.DeviceRole, fields='__all__', - filters=DeviceRoleFilter + filters=DeviceRoleFilter, + pagination=True ) class DeviceRoleType(OrganizationalObjectType): + parent: Annotated['DeviceRoleType', strawberry.lazy('dcim.graphql.types')] | None + children: List[Annotated['DeviceRoleType', strawberry.lazy('dcim.graphql.types')]] color: str config_template: Annotated["ConfigTemplateType", strawberry.lazy('extras.graphql.types')] | None @@ -316,7 +351,8 @@ class DeviceRoleType(OrganizationalObjectType): @strawberry_django.type( models.DeviceType, fields='__all__', - filters=DeviceTypeFilter + filters=DeviceTypeFilter, + pagination=True ) class DeviceTypeType(NetBoxObjectType): console_port_template_count: BigInt @@ -350,7 +386,8 @@ class DeviceTypeType(NetBoxObjectType): @strawberry_django.type( models.FrontPort, fields='__all__', - filters=FrontPortFilter + filters=FrontPortFilter, + pagination=True ) class FrontPortType(ModularComponentType, CabledObjectMixin): color: str @@ -360,7 +397,8 @@ class FrontPortType(ModularComponentType, CabledObjectMixin): @strawberry_django.type( models.FrontPortTemplate, fields='__all__', - filters=FrontPortTemplateFilter + filters=FrontPortTemplateFilter, + pagination=True ) class FrontPortTemplateType(ModularComponentTemplateType): color: str @@ -369,8 +407,9 @@ class FrontPortTemplateType(ModularComponentTemplateType): @strawberry_django.type( models.MACAddress, - exclude=('assigned_object_type', 'assigned_object_id'), - filters=MACAddressFilter + exclude=['assigned_object_type', 'assigned_object_id'], + filters=MACAddressFilter, + pagination=True ) class MACAddressType(NetBoxObjectType): mac_address: str @@ -385,8 +424,9 @@ class MACAddressType(NetBoxObjectType): @strawberry_django.type( models.Interface, - exclude=('_path',), - filters=InterfaceFilter + exclude=['_path'], + filters=InterfaceFilter, + pagination=True ) class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, PathEndpointMixin): _name: str @@ -413,7 +453,8 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P @strawberry_django.type( models.InterfaceTemplate, fields='__all__', - filters=InterfaceTemplateFilter + filters=InterfaceTemplateFilter, + pagination=True ) class InterfaceTemplateType(ModularComponentTemplateType): _name: str @@ -424,12 +465,13 @@ class InterfaceTemplateType(ModularComponentTemplateType): @strawberry_django.type( models.InventoryItem, - exclude=('component_type', 'component_id', 'parent'), - filters=InventoryItemFilter + exclude=['component_type', 'component_id', 'parent'], + filters=InventoryItemFilter, + pagination=True ) class InventoryItemType(ComponentType): role: Annotated["InventoryItemRoleType", strawberry.lazy('dcim.graphql.types')] | None - manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] + manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] | None child_items: List[Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')]] @@ -451,7 +493,8 @@ class InventoryItemType(ComponentType): @strawberry_django.type( models.InventoryItemRole, fields='__all__', - filters=InventoryItemRoleFilter + filters=InventoryItemRoleFilter, + pagination=True ) class InventoryItemRoleType(OrganizationalObjectType): color: str @@ -463,8 +506,9 @@ class InventoryItemRoleType(OrganizationalObjectType): @strawberry_django.type( models.Location, # fields='__all__', - exclude=('parent',), # bug - temp - filters=LocationFilter + exclude=['parent'], # bug - temp + filters=LocationFilter, + pagination=True ) class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, OrganizationalObjectType): site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] @@ -491,7 +535,8 @@ class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, Organi @strawberry_django.type( models.Manufacturer, fields='__all__', - filters=ManufacturerFilter + filters=ManufacturerFilter, + pagination=True ) class ManufacturerType(OrganizationalObjectType, ContactsMixin): @@ -505,7 +550,8 @@ class ManufacturerType(OrganizationalObjectType, ContactsMixin): @strawberry_django.type( models.Module, fields='__all__', - filters=ModuleFilter + filters=ModuleFilter, + pagination=True ) class ModuleType(NetBoxObjectType): device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] @@ -524,8 +570,9 @@ class ModuleType(NetBoxObjectType): @strawberry_django.type( models.ModuleBay, # fields='__all__', - exclude=('parent',), - filters=ModuleBayFilter + exclude=['parent'], + filters=ModuleBayFilter, + pagination=True ) class ModuleBayType(ModularComponentType): @@ -540,18 +587,31 @@ class ModuleBayType(ModularComponentType): @strawberry_django.type( models.ModuleBayTemplate, fields='__all__', - filters=ModuleBayTemplateFilter + filters=ModuleBayTemplateFilter, + pagination=True ) class ModuleBayTemplateType(ModularComponentTemplateType): pass +@strawberry_django.type( + models.ModuleTypeProfile, + fields='__all__', + filters=ModuleTypeProfileFilter, + pagination=True +) +class ModuleTypeProfileType(NetBoxObjectType): + module_types: List[Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')]] + + @strawberry_django.type( models.ModuleType, fields='__all__', - filters=ModuleTypeFilter + filters=ModuleTypeFilter, + pagination=True ) class ModuleTypeType(NetBoxObjectType): + profile: Annotated["ModuleTypeProfileType", strawberry.lazy('dcim.graphql.types')] | None manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] frontporttemplates: List[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]] @@ -567,7 +627,8 @@ class ModuleTypeType(NetBoxObjectType): @strawberry_django.type( models.Platform, fields='__all__', - filters=PlatformFilter + filters=PlatformFilter, + pagination=True ) class PlatformType(OrganizationalObjectType): manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] | None @@ -579,8 +640,9 @@ class PlatformType(OrganizationalObjectType): @strawberry_django.type( models.PowerFeed, - exclude=('_path',), - filters=PowerFeedFilter + exclude=['_path'], + filters=PowerFeedFilter, + pagination=True ) class PowerFeedType(NetBoxObjectType, CabledObjectMixin, PathEndpointMixin): power_panel: Annotated["PowerPanelType", strawberry.lazy('dcim.graphql.types')] @@ -590,8 +652,9 @@ class PowerFeedType(NetBoxObjectType, CabledObjectMixin, PathEndpointMixin): @strawberry_django.type( models.PowerOutlet, - exclude=('_path',), - filters=PowerOutletFilter + exclude=['_path'], + filters=PowerOutletFilter, + pagination=True ) class PowerOutletType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): power_port: Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')] | None @@ -601,7 +664,8 @@ class PowerOutletType(ModularComponentType, CabledObjectMixin, PathEndpointMixin @strawberry_django.type( models.PowerOutletTemplate, fields='__all__', - filters=PowerOutletTemplateFilter + filters=PowerOutletTemplateFilter, + pagination=True ) class PowerOutletTemplateType(ModularComponentTemplateType): power_port: Annotated["PowerPortTemplateType", strawberry.lazy('dcim.graphql.types')] | None @@ -610,7 +674,8 @@ class PowerOutletTemplateType(ModularComponentTemplateType): @strawberry_django.type( models.PowerPanel, fields='__all__', - filters=PowerPanelFilter + filters=PowerPanelFilter, + pagination=True ) class PowerPanelType(NetBoxObjectType, ContactsMixin): site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] @@ -621,8 +686,9 @@ class PowerPanelType(NetBoxObjectType, ContactsMixin): @strawberry_django.type( models.PowerPort, - exclude=('_path',), - filters=PowerPortFilter + exclude=['_path'], + filters=PowerPortFilter, + pagination=True ) class PowerPortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): @@ -632,7 +698,8 @@ class PowerPortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): @strawberry_django.type( models.PowerPortTemplate, fields='__all__', - filters=PowerPortTemplateFilter + filters=PowerPortTemplateFilter, + pagination=True ) class PowerPortTemplateType(ModularComponentTemplateType): poweroutlet_templates: List[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]] @@ -641,7 +708,8 @@ class PowerPortTemplateType(ModularComponentTemplateType): @strawberry_django.type( models.RackType, fields='__all__', - filters=RackTypeFilter + filters=RackTypeFilter, + pagination=True ) class RackTypeType(NetBoxObjectType): manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] @@ -650,7 +718,8 @@ class RackTypeType(NetBoxObjectType): @strawberry_django.type( models.Rack, fields='__all__', - filters=RackFilter + filters=RackFilter, + pagination=True ) class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType): site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] @@ -668,7 +737,8 @@ class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObje @strawberry_django.type( models.RackReservation, fields='__all__', - filters=RackReservationFilter + filters=RackReservationFilter, + pagination=True ) class RackReservationType(NetBoxObjectType): units: List[int] @@ -680,7 +750,8 @@ class RackReservationType(NetBoxObjectType): @strawberry_django.type( models.RackRole, fields='__all__', - filters=RackRoleFilter + filters=RackRoleFilter, + pagination=True ) class RackRoleType(OrganizationalObjectType): color: str @@ -691,7 +762,8 @@ class RackRoleType(OrganizationalObjectType): @strawberry_django.type( models.RearPort, fields='__all__', - filters=RearPortFilter + filters=RearPortFilter, + pagination=True ) class RearPortType(ModularComponentType, CabledObjectMixin): color: str @@ -702,7 +774,8 @@ class RearPortType(ModularComponentType, CabledObjectMixin): @strawberry_django.type( models.RearPortTemplate, fields='__all__', - filters=RearPortTemplateFilter + filters=RearPortTemplateFilter, + pagination=True ) class RearPortTemplateType(ModularComponentTemplateType): color: str @@ -712,9 +785,9 @@ class RearPortTemplateType(ModularComponentTemplateType): @strawberry_django.type( models.Region, - exclude=('parent',), - # fields='__all__', - filters=RegionFilter + exclude=['parent'], + filters=RegionFilter, + pagination=True ) class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): @@ -739,7 +812,8 @@ class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): @strawberry_django.type( models.Site, fields='__all__', - filters=SiteFilter + filters=SiteFilter, + pagination=True ) class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType): time_zone: str | None @@ -772,9 +846,9 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObje @strawberry_django.type( models.SiteGroup, - # fields='__all__', - exclude=('parent',), # bug - temp - filters=SiteGroupFilter + exclude=['parent'], # bug - temp + filters=SiteGroupFilter, + pagination=True ) class SiteGroupType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): @@ -799,7 +873,8 @@ class SiteGroupType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): @strawberry_django.type( models.VirtualChassis, fields='__all__', - filters=VirtualChassisFilter + filters=VirtualChassisFilter, + pagination=True ) class VirtualChassisType(NetBoxObjectType): member_count: BigInt @@ -811,7 +886,8 @@ class VirtualChassisType(NetBoxObjectType): @strawberry_django.type( models.VirtualDeviceContext, fields='__all__', - filters=VirtualDeviceContextFilter + filters=VirtualDeviceContextFilter, + pagination=True ) class VirtualDeviceContextType(NetBoxObjectType): device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None diff --git a/netbox/dcim/migrations/0002_squashed.py b/netbox/dcim/migrations/0002_squashed.py index 2e830560f..ae1966e58 100644 --- a/netbox/dcim/migrations/0002_squashed.py +++ b/netbox/dcim/migrations/0002_squashed.py @@ -7,11 +7,11 @@ import taggit.managers class Migration(migrations.Migration): dependencies = [ - ('dcim', '0001_initial'), + ('dcim', '0001_squashed'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('contenttypes', '0002_remove_content_type_name'), - ('extras', '0001_initial'), - ('tenancy', '0001_initial'), + ('extras', '0001_squashed'), + ('tenancy', '0001_squashed_0012'), ] replaces = [ diff --git a/netbox/dcim/migrations/0003_squashed_0130.py b/netbox/dcim/migrations/0003_squashed_0130.py index 0248d9ba1..490ab8e8b 100644 --- a/netbox/dcim/migrations/0003_squashed_0130.py +++ b/netbox/dcim/migrations/0003_squashed_0130.py @@ -5,12 +5,12 @@ import taggit.managers class Migration(migrations.Migration): dependencies = [ - ('dcim', '0002_auto_20160622_1821'), - ('virtualization', '0001_virtualization'), + ('dcim', '0002_squashed'), + ('virtualization', '0001_squashed_0022'), ('contenttypes', '0002_remove_content_type_name'), - ('ipam', '0001_initial'), - ('tenancy', '0001_initial'), - ('extras', '0002_custom_fields'), + ('ipam', '0001_squashed'), + ('tenancy', '0001_squashed_0012'), + ('extras', '0002_squashed_0059'), ] replaces = [ @@ -505,28 +505,6 @@ class Migration(migrations.Migration): model_name='cable', name='termination_a_type', field=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', @@ -536,28 +514,6 @@ class Migration(migrations.Migration): model_name='cable', name='termination_b_type', field=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', diff --git a/netbox/dcim/migrations/0131_squashed_0159.py b/netbox/dcim/migrations/0131_squashed_0159.py index 3866e8cc8..1c1f2ff38 100644 --- a/netbox/dcim/migrations/0131_squashed_0159.py +++ b/netbox/dcim/migrations/0131_squashed_0159.py @@ -43,12 +43,12 @@ class Migration(migrations.Migration): ] dependencies = [ - ('tenancy', '0012_standardize_models'), + ('tenancy', '0001_squashed_0012'), ('extras', '0002_squashed_0059'), - ('dcim', '0130_sitegroup'), + ('dcim', '0003_squashed_0130'), ('contenttypes', '0002_remove_content_type_name'), - ('ipam', '0053_asn_model'), - ('wireless', '0001_wireless'), + ('ipam', '0047_squashed_0053'), + ('wireless', '0001_squashed_0008'), ] operations = [ @@ -866,21 +866,6 @@ class Migration(migrations.Migration): 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='+', @@ -1238,21 +1223,6 @@ class Migration(migrations.Migration): '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='+', @@ -1478,28 +1448,6 @@ class Migration(migrations.Migration): ( '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', diff --git a/netbox/dcim/migrations/0160_squashed_0166.py b/netbox/dcim/migrations/0160_squashed_0166.py index 0deb58bab..5cff94f4a 100644 --- a/netbox/dcim/migrations/0160_squashed_0166.py +++ b/netbox/dcim/migrations/0160_squashed_0166.py @@ -18,9 +18,9 @@ class Migration(migrations.Migration): dependencies = [ ('ipam', '0047_squashed_0053'), - ('tenancy', '0009_standardize_description_comments'), - ('circuits', '0037_new_cabling_models'), - ('dcim', '0159_populate_cable_paths'), + ('tenancy', '0001_squashed_0012'), + ('circuits', '0003_squashed_0037'), + ('dcim', '0131_squashed_0159'), ] operations = [ diff --git a/netbox/dcim/migrations/0167_squashed_0182.py b/netbox/dcim/migrations/0167_squashed_0182.py index d0ad5379f..ba077ff4e 100644 --- a/netbox/dcim/migrations/0167_squashed_0182.py +++ b/netbox/dcim/migrations/0167_squashed_0182.py @@ -27,10 +27,10 @@ class Migration(migrations.Migration): ] dependencies = [ - ('extras', '0086_configtemplate'), - ('tenancy', '0010_tenant_relax_uniqueness'), + ('extras', '0060_squashed_0086'), + ('tenancy', '0002_squashed_0011'), ('ipam', '0047_squashed_0053'), - ('dcim', '0166_virtualdevicecontext'), + ('dcim', '0160_squashed_0166'), ] operations = [ diff --git a/netbox/dcim/migrations/0183_devicetype_exclude_from_utilization.py b/netbox/dcim/migrations/0183_devicetype_exclude_from_utilization.py index f9f2c20b4..2e3edb08a 100644 --- a/netbox/dcim/migrations/0183_devicetype_exclude_from_utilization.py +++ b/netbox/dcim/migrations/0183_devicetype_exclude_from_utilization.py @@ -5,7 +5,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('dcim', '0182_zero_length_cable_fix'), + ('dcim', '0167_squashed_0182'), ] operations = [ diff --git a/netbox/dcim/migrations/0199_macaddress.py b/netbox/dcim/migrations/0199_macaddress.py index ae18d5f63..c668858b4 100644 --- a/netbox/dcim/migrations/0199_macaddress.py +++ b/netbox/dcim/migrations/0199_macaddress.py @@ -31,13 +31,6 @@ class Migration(migrations.Migration): 'assigned_object_type', models.ForeignKey( blank=True, - limit_choices_to=models.Q( - models.Q( - models.Q(('app_label', 'dcim'), ('model', 'interface')), - models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), - _connector='OR', - ) - ), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', diff --git a/netbox/dcim/migrations/0201_add_power_outlet_status.py b/netbox/dcim/migrations/0201_add_power_outlet_status.py new file mode 100644 index 000000000..21fd32186 --- /dev/null +++ b/netbox/dcim/migrations/0201_add_power_outlet_status.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0200_populate_mac_addresses'), + ] + + operations = [ + migrations.AddField( + model_name='poweroutlet', + name='status', + field=models.CharField(default='enabled', max_length=50), + ), + ] diff --git a/netbox/dcim/migrations/0202_location_comments_region_comments_sitegroup_comments.py b/netbox/dcim/migrations/0202_location_comments_region_comments_sitegroup_comments.py new file mode 100644 index 000000000..ffdc5ba8a --- /dev/null +++ b/netbox/dcim/migrations/0202_location_comments_region_comments_sitegroup_comments.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0201_add_power_outlet_status'), + ] + + operations = [ + migrations.AddField( + model_name='location', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='region', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='sitegroup', + name='comments', + field=models.TextField(blank=True), + ), + ] diff --git a/netbox/dcim/migrations/0203_add_rack_outer_height.py b/netbox/dcim/migrations/0203_add_rack_outer_height.py new file mode 100644 index 000000000..2d2fef265 --- /dev/null +++ b/netbox/dcim/migrations/0203_add_rack_outer_height.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2b1 on 2025-03-18 15:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0202_location_comments_region_comments_sitegroup_comments'), + ] + + operations = [ + migrations.AddField( + model_name='rack', + name='outer_height', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='racktype', + name='outer_height', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/migrations/0203_device_role_nested.py b/netbox/dcim/migrations/0203_device_role_nested.py new file mode 100644 index 000000000..c9dd791b3 --- /dev/null +++ b/netbox/dcim/migrations/0203_device_role_nested.py @@ -0,0 +1,65 @@ +# Generated by Django 5.1.7 on 2025-03-25 18:06 + +import django.db.models.manager +import mptt.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0203_add_rack_outer_height'), + ] + + operations = [ + migrations.AddField( + model_name='devicerole', + name='level', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='devicerole', + name='lft', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='devicerole', + name='rght', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='devicerole', + name='tree_id', + field=models.PositiveIntegerField(db_index=True, default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='devicerole', + name='parent', + field=mptt.fields.TreeForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='children', + to='dcim.devicerole', + ), + ), + migrations.AddField( + model_name='devicerole', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AlterField( + model_name='devicerole', + name='name', + field=models.CharField(max_length=100), + ), + migrations.AlterField( + model_name='devicerole', + name='slug', + field=models.SlugField(max_length=100), + ), + ] diff --git a/netbox/dcim/migrations/0204_device_role_rebuild.py b/netbox/dcim/migrations/0204_device_role_rebuild.py new file mode 100644 index 000000000..69837c522 --- /dev/null +++ b/netbox/dcim/migrations/0204_device_role_rebuild.py @@ -0,0 +1,22 @@ +from django.db import migrations +import mptt +import mptt.managers + + +def rebuild_mptt(apps, schema_editor): + manager = mptt.managers.TreeManager() + DeviceRole = apps.get_model('dcim', 'DeviceRole') + manager.model = DeviceRole + mptt.register(DeviceRole) + manager.contribute_to_class(DeviceRole, 'objects') + manager.rebuild() + + +class Migration(migrations.Migration): + dependencies = [ + ('dcim', '0203_device_role_nested'), + ] + + operations = [ + migrations.RunPython(code=rebuild_mptt, reverse_code=migrations.RunPython.noop), + ] diff --git a/netbox/dcim/migrations/0205_moduletypeprofile.py b/netbox/dcim/migrations/0205_moduletypeprofile.py new file mode 100644 index 000000000..25ab3415b --- /dev/null +++ b/netbox/dcim/migrations/0205_moduletypeprofile.py @@ -0,0 +1,57 @@ +import django.db.models.deletion +import taggit.managers +from django.db import migrations, models + +import utilities.json + + +class Migration(migrations.Migration): + dependencies = [ + ('dcim', '0204_device_role_rebuild'), + ('extras', '0126_exporttemplate_file_name'), + ] + + operations = [ + migrations.CreateModel( + name='ModuleTypeProfile', + 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)), + ('comments', models.TextField(blank=True)), + ('name', models.CharField(max_length=100, unique=True)), + ('schema', models.JSONField(blank=True, null=True)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'verbose_name': 'module type profile', + 'verbose_name_plural': 'module type profiles', + 'ordering': ('name',), + }, + ), + migrations.AddField( + model_name='moduletype', + name='attribute_data', + field=models.JSONField(blank=True, null=True), + ), + migrations.AddField( + model_name='moduletype', + name='profile', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='module_types', + to='dcim.moduletypeprofile', + ), + ), + migrations.AlterModelOptions( + name='moduletype', + options={'ordering': ('profile', 'manufacturer', 'model')}, + ), + ] diff --git a/netbox/dcim/migrations/0206_load_module_type_profiles.py b/netbox/dcim/migrations/0206_load_module_type_profiles.py new file mode 100644 index 000000000..e3ca7d27a --- /dev/null +++ b/netbox/dcim/migrations/0206_load_module_type_profiles.py @@ -0,0 +1,42 @@ +import json +from pathlib import Path + +from django.db import migrations + +DATA_FILES_PATH = Path(__file__).parent / 'initial_data' / 'module_type_profiles' + + +def load_initial_data(apps, schema_editor): + """ + Load initial ModuleTypeProfile objects from file. + """ + ModuleTypeProfile = apps.get_model('dcim', 'ModuleTypeProfile') + initial_profiles = ( + 'cpu', + 'fan', + 'gpu', + 'hard_disk', + 'memory', + 'power_supply' + ) + + for name in initial_profiles: + file_path = DATA_FILES_PATH / f'{name}.json' + with file_path.open('r') as f: + data = json.load(f) + try: + ModuleTypeProfile.objects.create(**data) + except Exception as e: + print(f"Error loading data from {file_path}") + raise e + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0205_moduletypeprofile'), + ] + + operations = [ + migrations.RunPython(load_initial_data), + ] diff --git a/netbox/dcim/migrations/0207_remove_redundant_indexes.py b/netbox/dcim/migrations/0207_remove_redundant_indexes.py new file mode 100644 index 000000000..b63e6423f --- /dev/null +++ b/netbox/dcim/migrations/0207_remove_redundant_indexes.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2b1 on 2025-04-03 18:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0206_load_module_type_profiles'), + ] + + operations = [ + migrations.RemoveIndex( + model_name='cabletermination', + name='dcim_cablet_termina_884752_idx', + ), + ] diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/cpu.json b/netbox/dcim/migrations/initial_data/module_type_profiles/cpu.json new file mode 100644 index 000000000..255886c5e --- /dev/null +++ b/netbox/dcim/migrations/initial_data/module_type_profiles/cpu.json @@ -0,0 +1,20 @@ +{ + "name": "CPU", + "schema": { + "properties": { + "architecture": { + "type": "string", + "title": "Architecture" + }, + "speed": { + "type": "number", + "title": "Speed", + "description": "Clock speed in GHz" + }, + "cores": { + "type": "integer", + "description": "Number of cores present" + } + } + } +} diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/fan.json b/netbox/dcim/migrations/initial_data/module_type_profiles/fan.json new file mode 100644 index 000000000..e6a2a384e --- /dev/null +++ b/netbox/dcim/migrations/initial_data/module_type_profiles/fan.json @@ -0,0 +1,12 @@ +{ + "name": "Fan", + "schema": { + "properties": { + "rpm": { + "type": "integer", + "title": "RPM", + "description": "Fan speed (RPM)" + } + } + } +} diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/gpu.json b/netbox/dcim/migrations/initial_data/module_type_profiles/gpu.json new file mode 100644 index 000000000..1725a4ab7 --- /dev/null +++ b/netbox/dcim/migrations/initial_data/module_type_profiles/gpu.json @@ -0,0 +1,28 @@ +{ + "name": "GPU", + "schema": { + "properties": { + "interface": { + "type": "string", + "enum": [ + "PCIe 4.0", + "PCIe 4.0 x8", + "PCIe 4.0 x16", + "PCIe 5.0 x16" + ] + }, + "gpu" : { + "type": "string", + "title": "GPU" + }, + "memory": { + "type": "integer", + "title": "Memory (GB)", + "description": "Total memory capacity (in GB)" + } + }, + "required": [ + "memory" + ] + } +} diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/hard_disk.json b/netbox/dcim/migrations/initial_data/module_type_profiles/hard_disk.json new file mode 100644 index 000000000..8d55cfde6 --- /dev/null +++ b/netbox/dcim/migrations/initial_data/module_type_profiles/hard_disk.json @@ -0,0 +1,29 @@ +{ + "name": "Hard disk", + "schema": { + "properties": { + "type": { + "type": "string", + "title": "Disk type", + "enum": [ + "HD", + "SSD", + "NVME" + ], + "default": "SSD" + }, + "size": { + "type": "integer", + "title": "Size (GB)", + "description": "Raw disk capacity" + }, + "speed": { + "type": "integer", + "title": "Speed (RPM)" + } + }, + "required": [ + "size" + ] + } +} diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/memory.json b/netbox/dcim/migrations/initial_data/module_type_profiles/memory.json new file mode 100644 index 000000000..8346bfce9 --- /dev/null +++ b/netbox/dcim/migrations/initial_data/module_type_profiles/memory.json @@ -0,0 +1,36 @@ +{ + "name": "Memory", + "schema": { + "properties": { + "class": { + "type": "string", + "title": "Memory class", + "enum": [ + "DDR3", + "DDR4", + "DDR5" + ], + "default": "DDR5" + }, + "size": { + "type": "integer", + "title": "Size (GB)", + "description": "Raw capacity of the module" + }, + "data_rate": { + "type": "integer", + "title": "Data rate", + "description": "Speed in MT/s" + }, + "ecc": { + "type": "boolean", + "title": "ECC", + "description": "Error-correcting code is enabled" + } + }, + "required": [ + "class", + "size" + ] + } +} diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/power_supply.json b/netbox/dcim/migrations/initial_data/module_type_profiles/power_supply.json new file mode 100644 index 000000000..ea060a889 --- /dev/null +++ b/netbox/dcim/migrations/initial_data/module_type_profiles/power_supply.json @@ -0,0 +1,34 @@ +{ + "name": "Power supply", + "schema": { + "properties": { + "input_current": { + "type": "string", + "title": "Current type", + "enum": [ + "AC", + "DC" + ], + "default": "AC" + }, + "input_voltage": { + "type": "integer", + "title": "Voltage", + "default": 120 + }, + "wattage": { + "type": "integer", + "description": "Available output power (watts)" + }, + "hot_swappable": { + "type": "boolean", + "title": "Hot-swappable", + "default": false + } + }, + "required": [ + "input_current", + "input_voltage" + ] + } +} diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index d74f34828..33af25678 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -2,6 +2,7 @@ from .cables import * from .device_component_templates import * from .device_components import * from .devices import * +from .modules import * from .power import * from .racks import * from .sites import * diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 7117ea7e0..0a28d5acb 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -1,10 +1,8 @@ import itertools -from collections import defaultdict from django.contrib.contenttypes.fields import GenericForeignKey from django.core.exceptions import ValidationError from django.db import models -from django.db.models import Sum from django.dispatch import Signal from django.utils.translation import gettext_lazy as _ @@ -15,7 +13,8 @@ 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.exceptions import AbortRequest +from utilities.fields import ColorField, GenericArrayForeignKey from utilities.querysets import RestrictedQuerySet from wireless.models import WirelessLink from .device_components import FrontPort, RearPort, PathEndpoint @@ -26,6 +25,7 @@ __all__ = ( 'CableTermination', ) +from ..exceptions import UnsupportedCablePath trace_paths = Signal() @@ -236,8 +236,10 @@ class Cable(PrimaryModel): for termination in self.b_terminations: if not termination.pk or termination not in b_terminations: CableTermination(cable=self, cable_end='B', termination=termination).save() - - trace_paths.send(Cable, instance=self, created=_created) + try: + trace_paths.send(Cable, instance=self, created=_created) + except UnsupportedCablePath as e: + raise AbortRequest(e) def get_status_color(self): return LinkStatusChoices.colors.get(self.status) @@ -259,7 +261,6 @@ class CableTermination(ChangeLoggedModel): ) termination_type = models.ForeignKey( to='contenttypes.ContentType', - limit_choices_to=CABLE_TERMINATION_MODELS, on_delete=models.PROTECT, related_name='+' ) @@ -299,9 +300,6 @@ class CableTermination(ChangeLoggedModel): class Meta: ordering = ('cable', 'cable_end', 'pk') - indexes = ( - models.Index(fields=('termination_type', 'termination_id')), - ) constraints = ( models.UniqueConstraint( fields=('termination_type', 'termination_id'), @@ -490,13 +488,16 @@ class CablePath(models.Model): return ObjectType.objects.get_for_id(ct_id) @property - def path_objects(self): - """ - Cache and return the complete path as lists of objects, derived from their annotation within the path. - """ - if not hasattr(self, '_path_objects'): - self._path_objects = self._get_path() - return self._path_objects + def _path_decompiled(self): + res = [] + for step in self.path: + nodes = [] + for node in step: + nodes.append(decompile_path_node(node)) + res.append(nodes) + return res + + path_objects = GenericArrayForeignKey("_path_decompiled") @property def origins(self): @@ -531,8 +532,8 @@ class CablePath(models.Model): return None # Ensure all originating terminations are attached to the same link - if len(terminations) > 1: - assert all(t.link == terminations[0].link for t in terminations[1:]) + if len(terminations) > 1 and not all(t.link == terminations[0].link for t in terminations[1:]): + raise UnsupportedCablePath(_("All originating terminations must be attached to the same link")) path = [] position_stack = [] @@ -543,12 +544,13 @@ class CablePath(models.Model): while terminations: # Terminations must all be of the same type - assert all(isinstance(t, type(terminations[0])) for t in terminations[1:]) + if not all(isinstance(t, type(terminations[0])) for t in terminations[1:]): + raise UnsupportedCablePath(_("All mid-span terminations must have the same termination type")) # All mid-span terminations must all be attached to the same device - if not isinstance(terminations[0], PathEndpoint): - assert all(isinstance(t, type(terminations[0])) for t in terminations[1:]) - assert all(t.parent_object == terminations[0].parent_object for t in terminations[1:]) + if (not isinstance(terminations[0], PathEndpoint) and not + all(t.parent_object == terminations[0].parent_object for t in terminations[1:])): + raise UnsupportedCablePath(_("All mid-span terminations must have the same parent object")) # Check for a split path (e.g. rear port fanning out to multiple front ports with # different cables attached) @@ -571,8 +573,10 @@ class CablePath(models.Model): return None # Otherwise, halt the trace if no link exists break - assert all(type(link) in (Cable, WirelessLink) for link in links) - assert all(isinstance(link, type(links[0])) for link in links) + if not all(type(link) in (Cable, WirelessLink) for link in links): + raise UnsupportedCablePath(_("All links must be cable or wireless")) + if not all(isinstance(link, type(links[0])) for link in links): + raise UnsupportedCablePath(_("All links must match first link type")) # Step 3: Record asymmetric paths as split not_connected_terminations = [termination.link for termination in terminations if termination.link is None] @@ -653,14 +657,18 @@ class CablePath(models.Model): positions = position_stack.pop() # Ensure we have a number of positions equal to the amount of remote terminations - assert len(remote_terminations) == len(positions) + if len(remote_terminations) != len(positions): + raise UnsupportedCablePath( + _("All positions counts within the path on opposite ends of links must match") + ) # Get our front ports q_filter = Q() for rt in remote_terminations: position = positions.pop() q_filter |= Q(rear_port_id=rt.pk, rear_port_position=position) - assert q_filter is not Q() + if q_filter is Q(): + raise UnsupportedCablePath(_("Remote termination position filter is missing")) front_ports = FrontPort.objects.filter(q_filter) # Obtain the individual front ports based on the termination and position elif position_stack: @@ -746,42 +754,6 @@ class CablePath(models.Model): self.delete() retrace.alters_data = True - def _get_path(self): - """ - Return the path as a list of prefetched objects. - """ - # Compile a list of IDs to prefetch for each type of model in the path - to_prefetch = defaultdict(list) - for node in self._nodes: - ct_id, object_id = decompile_path_node(node) - to_prefetch[ct_id].append(object_id) - - # 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 = 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') - prefetched[ct_id] = { - obj.id: obj for obj in queryset - } - - # Replicate the path using the prefetched objects. - path = [] - for step in self.path: - nodes = [] - for node in step: - ct_id, object_id = decompile_path_node(node) - try: - nodes.append(prefetched[ct_id][object_id]) - except KeyError: - # Ignore stale (deleted) object IDs - pass - path.append(nodes) - - return path - def get_cable_ids(self): """ Return all Cable IDs within the path. @@ -801,9 +773,28 @@ class CablePath(models.Model): Return a tuple containing the sum of the length of each cable in the path and a flag indicating whether the length is definitive. """ + cable_ct = ObjectType.objects.get_for_model(Cable).pk + + # Pre-cache cable lengths by ID cable_ids = self.get_cable_ids() - cables = Cable.objects.filter(id__in=cable_ids, _abs_length__isnull=False) - total_length = cables.aggregate(total=Sum('_abs_length'))['total'] + cables = { + cable['pk']: cable['_abs_length'] + for cable in Cable.objects.filter(id__in=cable_ids, _abs_length__isnull=False).values('pk', '_abs_length') + } + + # Iterate through each set of nodes in the path. For cables, add the length of the longest cable to the total + # length of the path. + total_length = 0 + for node_set in self.path: + hop_length = 0 + for node in node_set: + ct, pk = decompile_path_node(node) + if ct != cable_ct: + break # Not a cable + if pk in cables and cables[pk] > hop_length: + hop_length = cables[pk] + total_length += hop_length + is_definitive = len(cables) == len(cable_ids) return total_length, is_definitive diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index b4f057711..e0b05b388 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -751,7 +751,6 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel): ) component_type = models.ForeignKey( to='contenttypes.ContentType', - limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS, on_delete=models.PROTECT, related_name='+', blank=True, diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index ce9e5607f..4b44c5b4e 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -184,8 +184,11 @@ class CabledObjectModel(models.Model): @cached_property def link_peers(self): if self.cable: - peers = self.cable.terminations.exclude(cable_end=self.cable_end).prefetch_related('termination') - return [peer.termination for peer in peers] + return [ + peer.termination + for peer in self.cable.terminations.all() + if peer.cable_end != self.cable_end + ] return [] @property @@ -449,6 +452,12 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki """ A physical power outlet (output) within a Device which provides power to a PowerPort. """ + status = models.CharField( + verbose_name=_('status'), + max_length=50, + choices=PowerOutletStatusChoices, + default=PowerOutletStatusChoices.STATUS_ENABLED + ) type = models.CharField( verbose_name=_('type'), max_length=50, @@ -492,6 +501,9 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki _("Parent power port ({power_port}) must belong to the same device").format(power_port=self.power_port) ) + def get_status_color(self): + return PowerOutletStatusChoices.colors.get(self.status) + # # Interfaces @@ -934,6 +946,8 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd raise ValidationError({'rf_channel_width': _("Cannot specify custom width with channel selected.")}) # VLAN validation + if not self.mode and self.untagged_vlan: + raise ValidationError({'untagged_vlan': _("Interface mode does not support an untagged vlan.")}) # Validate untagged VLAN if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]: @@ -1263,7 +1277,6 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin): ) component_type = models.ForeignKey( to='contenttypes.ContentType', - limit_choices_to=MODULAR_COMPONENT_MODELS, on_delete=models.PROTECT, related_name='+', blank=True, diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index dbcd91ea0..5988f8241 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -3,7 +3,7 @@ import yaml from functools import cached_property -from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.core.exceptions import ValidationError from django.core.files.storage import default_storage from django.core.validators import MaxValueValidator, MinValueValidator @@ -15,20 +15,23 @@ from django.urls import reverse from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ +from core.models import ObjectType from dcim.choices import * from dcim.constants import * from dcim.fields import MACAddressField +from dcim.utils import update_interface_bridges 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 import NestedGroupModel, OrganizationalModel, PrimaryModel from netbox.models.mixins import WeightMixin from netbox.models.features import ContactsMixin, ImageAttachmentsMixin from utilities.fields import ColorField, CounterCacheField from utilities.tracking import TrackingModelMixin from .device_components import * from .mixins import RenderConfigMixin +from .modules import Module __all__ = ( @@ -37,8 +40,6 @@ __all__ = ( 'DeviceType', 'MACAddress', 'Manufacturer', - 'Module', - 'ModuleType', 'Platform', 'VirtualChassis', 'VirtualDeviceContext', @@ -366,108 +367,11 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD -class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): - """ - A ModuleType represents a hardware element that can be installed within a device and which houses additional - components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a - DeviceType, each ModuleType can have console, power, interface, and pass-through port templates assigned to it. It - cannot, however house device bays or module bays. - """ - manufacturer = models.ForeignKey( - to='dcim.Manufacturer', - on_delete=models.PROTECT, - related_name='module_types' - ) - model = models.CharField( - verbose_name=_('model'), - max_length=100 - ) - part_number = models.CharField( - verbose_name=_('part number'), - max_length=50, - blank=True, - help_text=_('Discrete part number (optional)') - ) - airflow = models.CharField( - verbose_name=_('airflow'), - max_length=50, - choices=ModuleAirflowChoices, - blank=True, - null=True - ) - - clone_fields = ('manufacturer', 'weight', 'weight_unit', 'airflow') - prerequisite_models = ( - 'dcim.Manufacturer', - ) - - class Meta: - ordering = ('manufacturer', 'model') - constraints = ( - models.UniqueConstraint( - fields=('manufacturer', 'model'), - name='%(app_label)s_%(class)s_unique_manufacturer_model' - ), - ) - verbose_name = _('module type') - verbose_name_plural = _('module types') - - def __str__(self): - return self.model - - @property - def full_name(self): - return f"{self.manufacturer} {self.model}" - - def to_yaml(self): - data = { - 'manufacturer': self.manufacturer.name, - 'model': self.model, - 'part_number': self.part_number, - '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 - if self.consoleporttemplates.exists(): - data['console-ports'] = [ - c.to_yaml() for c in self.consoleporttemplates.all() - ] - if self.consoleserverporttemplates.exists(): - data['console-server-ports'] = [ - c.to_yaml() for c in self.consoleserverporttemplates.all() - ] - if self.powerporttemplates.exists(): - data['power-ports'] = [ - c.to_yaml() for c in self.powerporttemplates.all() - ] - if self.poweroutlettemplates.exists(): - data['power-outlets'] = [ - c.to_yaml() for c in self.poweroutlettemplates.all() - ] - if self.interfacetemplates.exists(): - data['interfaces'] = [ - c.to_yaml() for c in self.interfacetemplates.all() - ] - if self.frontporttemplates.exists(): - data['front-ports'] = [ - c.to_yaml() for c in self.frontporttemplates.all() - ] - if self.rearporttemplates.exists(): - data['rear-ports'] = [ - c.to_yaml() for c in self.rearporttemplates.all() - ] - - return yaml.dump(dict(data), sort_keys=False) - - # # Devices # -class DeviceRole(OrganizationalModel): +class DeviceRole(NestedGroupModel): """ Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to @@ -490,6 +394,8 @@ class DeviceRole(OrganizationalModel): null=True ) + clone_fields = ('parent', 'description') + class Meta: ordering = ('name',) verbose_name = _('device role') @@ -523,23 +429,6 @@ class Platform(OrganizationalModel): verbose_name_plural = _('platforms') -def update_interface_bridges(device, interface_templates, module=None): - """ - Used for device and module instantiation. Iterates all InterfaceTemplates with a bridge assigned - and applies it to the actual interfaces. - """ - for interface_template in interface_templates.exclude(bridge=None): - interface = Interface.objects.get(device=device, name=interface_template.resolve_name(module=module)) - - if interface_template.bridge: - interface.bridge = Interface.objects.get( - device=device, - name=interface_template.bridge.resolve_name(module=module) - ) - interface.full_clean() - interface.save() - - class Device( ContactsMixin, ImageAttachmentsMixin, @@ -720,6 +609,12 @@ class Device( null=True, help_text=_("GPS coordinate in decimal format (xx.yyyyyy)") ) + services = GenericRelation( + to='ipam.Service', + content_type_field='parent_object_type', + object_id_field='parent_object_id', + related_query_name='device', + ) # Counter fields console_port_count = CounterCacheField( @@ -801,14 +696,10 @@ class Device( verbose_name_plural = _('devices') def __str__(self): - if self.name and self.asset_tag: - return f'{self.name} ({self.asset_tag})' - elif self.name: - return self.name - elif self.virtual_chassis and self.asset_tag: - return f'{self.virtual_chassis.name}:{self.vc_position} ({self.asset_tag})' - elif self.virtual_chassis: - return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})' + if self.label and self.asset_tag: + return f'{self.label} ({self.asset_tag})' + elif self.label: + return self.label elif self.device_type and self.asset_tag: return f'{self.device_type.manufacturer} {self.device_type.model} ({self.asset_tag})' elif self.device_type: @@ -1072,14 +963,22 @@ class Device( device.location = self.location device.save() + @property + def label(self): + """ + Return the device name if set; otherwise return a generated name if available. + """ + if self.name: + return self.name + if self.virtual_chassis: + return f'{self.virtual_chassis.name}:{self.vc_position}' + @property def identifier(self): """ Return the device name if set; otherwise return the Device's primary key as {pk} """ - if self.name is not None: - return self.name - return '{{{}}}'.format(self.pk) + return self.label or '{{{}}}'.format(self.pk) @property def primary_ip(self): @@ -1152,170 +1051,6 @@ class Device( return round(total_weight / 1000, 2) -class Module(PrimaryModel, ConfigContextModel): - """ - A Module represents a field-installable component within a Device which may itself hold multiple device components - (for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes. - """ - device = models.ForeignKey( - to='dcim.Device', - on_delete=models.CASCADE, - related_name='modules' - ) - module_bay = models.OneToOneField( - to='dcim.ModuleBay', - on_delete=models.CASCADE, - related_name='installed_module' - ) - module_type = models.ForeignKey( - to='dcim.ModuleType', - on_delete=models.PROTECT, - related_name='instances' - ) - status = models.CharField( - verbose_name=_('status'), - max_length=50, - choices=ModuleStatusChoices, - default=ModuleStatusChoices.STATUS_ACTIVE - ) - serial = models.CharField( - max_length=50, - blank=True, - verbose_name=_('serial number') - ) - asset_tag = models.CharField( - max_length=50, - blank=True, - null=True, - unique=True, - verbose_name=_('asset tag'), - help_text=_('A unique tag used to identify this device') - ) - - clone_fields = ('device', 'module_type', 'status') - - class Meta: - ordering = ('module_bay',) - verbose_name = _('module') - verbose_name_plural = _('modules') - - def __str__(self): - return f'{self.module_bay.name}: {self.module_type} ({self.pk})' - - def get_status_color(self): - return ModuleStatusChoices.colors.get(self.status) - - def clean(self): - super().clean() - - if hasattr(self, "module_bay") and (self.module_bay.device != self.device): - raise ValidationError( - _("Module must be installed within a module bay belonging to the assigned device ({device}).").format( - device=self.device - ) - ) - - # Check for recursion - module = self - module_bays = [] - modules = [] - while module: - if module.pk in modules or module.module_bay.pk in module_bays: - raise ValidationError(_("A module bay cannot belong to a module installed within it.")) - modules.append(module.pk) - module_bays.append(module.module_bay.pk) - module = module.module_bay.module if module.module_bay else None - - def save(self, *args, **kwargs): - is_new = self.pk is None - - super().save(*args, **kwargs) - - adopt_components = getattr(self, '_adopt_components', False) - disable_replication = getattr(self, '_disable_replication', False) - - # We skip adding components if the module is being edited or - # both replication and component adoption is disabled - if not is_new or (disable_replication and not adopt_components): - return - - # Iterate all component types - for templates, component_attribute, component_model in [ - ("consoleporttemplates", "consoleports", ConsolePort), - ("consoleserverporttemplates", "consoleserverports", ConsoleServerPort), - ("interfacetemplates", "interfaces", Interface), - ("powerporttemplates", "powerports", PowerPort), - ("poweroutlettemplates", "poweroutlets", PowerOutlet), - ("rearporttemplates", "rearports", RearPort), - ("frontporttemplates", "frontports", FrontPort), - ("modulebaytemplates", "modulebays", ModuleBay), - ]: - create_instances = [] - update_instances = [] - - # Prefetch installed components - installed_components = { - component.name: component - for component in getattr(self.device, component_attribute).filter(module__isnull=True) - } - - # Get the template for the module type. - for template in getattr(self.module_type, templates).all(): - template_instance = template.instantiate(device=self.device, module=self) - - if adopt_components: - existing_item = installed_components.get(template_instance.name) - - # Check if there's a component with the same name already - if existing_item: - # Assign it to the module - existing_item.module = self - update_instances.append(existing_item) - continue - - # Only create new components if replication is enabled - if not disable_replication: - create_instances.append(template_instance) - - # Set default values for any applicable custom fields - if cf_defaults := CustomField.objects.get_defaults_for_model(component_model): - for component in create_instances: - component.custom_field_data = cf_defaults - - if component_model is not ModuleBay: - component_model.objects.bulk_create(create_instances) - # Emit the post_save signal for each newly created object - for component in create_instances: - post_save.send( - sender=component_model, - instance=component, - created=True, - raw=False, - using='default', - update_fields=None - ) - else: - # ModuleBays must be saved individually for MPTT - for instance in create_instances: - instance.save() - - update_fields = ['module'] - component_model.objects.bulk_update(update_instances, update_fields) - # Emit the post_save signal for each updated object - for component in update_instances: - post_save.send( - sender=component_model, - instance=component, - created=False, - raw=False, - using='default', - update_fields=update_fields - ) - - # Interface bridges have to be set after interface instantiation - update_interface_bridges(self.device, self.module_type.interfacetemplates, self) - - # # Virtual chassis # @@ -1500,7 +1235,6 @@ class MACAddress(PrimaryModel): ) assigned_object_type = models.ForeignKey( to='contenttypes.ContentType', - limit_choices_to=MACADDRESS_ASSIGNMENT_MODELS, on_delete=models.PROTECT, related_name='+', blank=True, @@ -1522,3 +1256,37 @@ class MACAddress(PrimaryModel): def __str__(self): return str(self.mac_address) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Denote the original assigned object (if any) for validation in clean() + self._original_assigned_object_id = self.__dict__.get('assigned_object_id') + self._original_assigned_object_type_id = self.__dict__.get('assigned_object_type_id') + + @cached_property + def is_primary(self): + if self.assigned_object and hasattr(self.assigned_object, 'primary_mac_address'): + if self.assigned_object.primary_mac_address and self.assigned_object.primary_mac_address.pk == self.pk: + return True + return False + + def clean(self, *args, **kwargs): + super().clean() + if self._original_assigned_object_id and self._original_assigned_object_type_id: + assigned_object = self.assigned_object + ct = ObjectType.objects.get_for_id(self._original_assigned_object_type_id) + original_assigned_object = ct.get_object_for_this_type(pk=self._original_assigned_object_id) + + if ( + original_assigned_object.primary_mac_address + and original_assigned_object.primary_mac_address.pk == self.pk + ): + if not assigned_object: + raise ValidationError( + _("Cannot unassign MAC Address while it is designated as the primary MAC for an object") + ) + elif original_assigned_object != assigned_object: + raise ValidationError( + _("Cannot reassign MAC Address while it is designated as the primary MAC for an object") + ) diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py index a0fc15a25..127dfb9e5 100644 --- a/netbox/dcim/models/mixins.py +++ b/netbox/dcim/models/mixins.py @@ -3,7 +3,6 @@ from django.contrib.contenttypes.fields import GenericForeignKey from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ -from dcim.constants import LOCATION_SCOPE_TYPES __all__ = ( 'CachedScopeMixin', @@ -44,7 +43,6 @@ class CachedScopeMixin(models.Model): scope_type = models.ForeignKey( to='contenttypes.ContentType', on_delete=models.PROTECT, - limit_choices_to=models.Q(model__in=LOCATION_SCOPE_TYPES), related_name='+', blank=True, null=True diff --git a/netbox/dcim/models/modules.py b/netbox/dcim/models/modules.py new file mode 100644 index 000000000..c5830f1db --- /dev/null +++ b/netbox/dcim/models/modules.py @@ -0,0 +1,362 @@ +import jsonschema +import yaml +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models.signals import post_save +from django.utils.translation import gettext_lazy as _ +from jsonschema.exceptions import ValidationError as JSONValidationError + +from dcim.choices import * +from dcim.constants import MODULE_TOKEN +from dcim.utils import update_interface_bridges +from extras.models import ConfigContextModel, CustomField +from netbox.models import PrimaryModel +from netbox.models.features import ImageAttachmentsMixin +from netbox.models.mixins import WeightMixin +from utilities.jsonschema import validate_schema +from utilities.string import title +from .device_components import * + +__all__ = ( + 'Module', + 'ModuleType', + 'ModuleTypeProfile', +) + + +class ModuleTypeProfile(PrimaryModel): + """ + A profile which defines the attributes which can be set on one or more ModuleTypes. + """ + name = models.CharField( + verbose_name=_('name'), + max_length=100, + unique=True + ) + schema = models.JSONField( + blank=True, + null=True, + verbose_name=_('schema') + ) + + clone_fields = ('schema',) + + class Meta: + ordering = ('name',) + verbose_name = _('module type profile') + verbose_name_plural = _('module type profiles') + + def __str__(self): + return self.name + + def clean(self): + super().clean() + + # Validate the schema definition + if self.schema is not None: + try: + validate_schema(self.schema) + except ValidationError as e: + raise ValidationError({ + 'schema': e.message, + }) + + +class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): + """ + A ModuleType represents a hardware element that can be installed within a device and which houses additional + components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a + DeviceType, each ModuleType can have console, power, interface, and pass-through port templates assigned to it. It + cannot, however house device bays or module bays. + """ + profile = models.ForeignKey( + to='dcim.ModuleTypeProfile', + on_delete=models.PROTECT, + related_name='module_types', + blank=True, + null=True + ) + manufacturer = models.ForeignKey( + to='dcim.Manufacturer', + on_delete=models.PROTECT, + related_name='module_types' + ) + model = models.CharField( + verbose_name=_('model'), + max_length=100 + ) + part_number = models.CharField( + verbose_name=_('part number'), + max_length=50, + blank=True, + help_text=_('Discrete part number (optional)') + ) + airflow = models.CharField( + verbose_name=_('airflow'), + max_length=50, + choices=ModuleAirflowChoices, + blank=True, + null=True + ) + attribute_data = models.JSONField( + blank=True, + null=True, + verbose_name=_('attributes') + ) + + clone_fields = ('profile', 'manufacturer', 'weight', 'weight_unit', 'airflow') + prerequisite_models = ( + 'dcim.Manufacturer', + ) + + class Meta: + ordering = ('profile', 'manufacturer', 'model') + constraints = ( + models.UniqueConstraint( + fields=('manufacturer', 'model'), + name='%(app_label)s_%(class)s_unique_manufacturer_model' + ), + ) + verbose_name = _('module type') + verbose_name_plural = _('module types') + + def __str__(self): + return self.model + + @property + def full_name(self): + return f"{self.manufacturer} {self.model}" + + @property + def attributes(self): + """ + Returns a human-friendly representation of the attributes defined for a ModuleType according to its profile. + """ + if not self.attribute_data or self.profile is None or not self.profile.schema: + return {} + attrs = {} + for name, options in self.profile.schema.get('properties', {}).items(): + key = options.get('title', title(name)) + attrs[key] = self.attribute_data.get(name) + return dict(sorted(attrs.items())) + + def clean(self): + super().clean() + + # Validate any attributes against the assigned profile's schema + if self.profile: + try: + jsonschema.validate(self.attribute_data, schema=self.profile.schema) + except JSONValidationError as e: + raise ValidationError(_("Invalid schema: {error}").format(error=e)) + else: + self.attribute_data = None + + def to_yaml(self): + data = { + 'profile': self.profile.name if self.profile else None, + 'manufacturer': self.manufacturer.name, + 'model': self.model, + 'part_number': self.part_number, + '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 + if self.consoleporttemplates.exists(): + data['console-ports'] = [ + c.to_yaml() for c in self.consoleporttemplates.all() + ] + if self.consoleserverporttemplates.exists(): + data['console-server-ports'] = [ + c.to_yaml() for c in self.consoleserverporttemplates.all() + ] + if self.powerporttemplates.exists(): + data['power-ports'] = [ + c.to_yaml() for c in self.powerporttemplates.all() + ] + if self.poweroutlettemplates.exists(): + data['power-outlets'] = [ + c.to_yaml() for c in self.poweroutlettemplates.all() + ] + if self.interfacetemplates.exists(): + data['interfaces'] = [ + c.to_yaml() for c in self.interfacetemplates.all() + ] + if self.frontporttemplates.exists(): + data['front-ports'] = [ + c.to_yaml() for c in self.frontporttemplates.all() + ] + if self.rearporttemplates.exists(): + data['rear-ports'] = [ + c.to_yaml() for c in self.rearporttemplates.all() + ] + + return yaml.dump(dict(data), sort_keys=False) + + +class Module(PrimaryModel, ConfigContextModel): + """ + A Module represents a field-installable component within a Device which may itself hold multiple device components + (for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes. + """ + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='modules' + ) + module_bay = models.OneToOneField( + to='dcim.ModuleBay', + on_delete=models.CASCADE, + related_name='installed_module' + ) + module_type = models.ForeignKey( + to='dcim.ModuleType', + on_delete=models.PROTECT, + related_name='instances' + ) + status = models.CharField( + verbose_name=_('status'), + max_length=50, + choices=ModuleStatusChoices, + default=ModuleStatusChoices.STATUS_ACTIVE + ) + serial = models.CharField( + max_length=50, + blank=True, + verbose_name=_('serial number') + ) + asset_tag = models.CharField( + max_length=50, + blank=True, + null=True, + unique=True, + verbose_name=_('asset tag'), + help_text=_('A unique tag used to identify this device') + ) + + clone_fields = ('device', 'module_type', 'status') + + class Meta: + ordering = ('module_bay',) + verbose_name = _('module') + verbose_name_plural = _('modules') + + def __str__(self): + return f'{self.module_bay.name}: {self.module_type} ({self.pk})' + + def get_status_color(self): + return ModuleStatusChoices.colors.get(self.status) + + def clean(self): + super().clean() + + if hasattr(self, "module_bay") and (self.module_bay.device != self.device): + raise ValidationError( + _("Module must be installed within a module bay belonging to the assigned device ({device}).").format( + device=self.device + ) + ) + + # Check for recursion + module = self + module_bays = [] + modules = [] + while module: + if module.pk in modules or module.module_bay.pk in module_bays: + raise ValidationError(_("A module bay cannot belong to a module installed within it.")) + modules.append(module.pk) + module_bays.append(module.module_bay.pk) + module = module.module_bay.module if module.module_bay else None + + def save(self, *args, **kwargs): + is_new = self.pk is None + + super().save(*args, **kwargs) + + adopt_components = getattr(self, '_adopt_components', False) + disable_replication = getattr(self, '_disable_replication', False) + + # We skip adding components if the module is being edited or + # both replication and component adoption is disabled + if not is_new or (disable_replication and not adopt_components): + return + + # Iterate all component types + for templates, component_attribute, component_model in [ + ("consoleporttemplates", "consoleports", ConsolePort), + ("consoleserverporttemplates", "consoleserverports", ConsoleServerPort), + ("interfacetemplates", "interfaces", Interface), + ("powerporttemplates", "powerports", PowerPort), + ("poweroutlettemplates", "poweroutlets", PowerOutlet), + ("rearporttemplates", "rearports", RearPort), + ("frontporttemplates", "frontports", FrontPort), + ("modulebaytemplates", "modulebays", ModuleBay), + ]: + create_instances = [] + update_instances = [] + + # Prefetch installed components + installed_components = { + component.name: component + for component in getattr(self.device, component_attribute).filter(module__isnull=True) + } + + # Get the template for the module type. + for template in getattr(self.module_type, templates).all(): + template_instance = template.instantiate(device=self.device, module=self) + + if adopt_components: + existing_item = installed_components.get(template_instance.name) + + # Check if there's a component with the same name already + if existing_item: + # Assign it to the module + existing_item.module = self + update_instances.append(existing_item) + continue + + # Only create new components if replication is enabled + if not disable_replication: + create_instances.append(template_instance) + + # Set default values for any applicable custom fields + if cf_defaults := CustomField.objects.get_defaults_for_model(component_model): + for component in create_instances: + component.custom_field_data = cf_defaults + + if component_model is not ModuleBay: + component_model.objects.bulk_create(create_instances) + # Emit the post_save signal for each newly created object + for component in create_instances: + post_save.send( + sender=component_model, + instance=component, + created=True, + raw=False, + using='default', + update_fields=None + ) + else: + # ModuleBays must be saved individually for MPTT + for instance in create_instances: + instance.name = instance.name.replace(MODULE_TOKEN, str(self.module_bay.position)) + instance.save() + + update_fields = ['module'] + component_model.objects.bulk_update(update_instances, update_fields) + # Emit the post_save signal for each updated object + for component in update_instances: + post_save.send( + sender=component_model, + instance=component, + created=False, + raw=False, + using='default', + update_fields=update_fields + ) + + # Interface bridges have to be set after interface instantiation + update_interface_bridges(self.device, self.module_type.interfacetemplates, self) diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 78eb0ea4a..b15cd8b34 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -73,6 +73,12 @@ class RackBase(WeightMixin, PrimaryModel): null=True, help_text=_('Outer dimension of rack (width)') ) + outer_height = models.PositiveSmallIntegerField( + verbose_name=_('outer height'), + blank=True, + null=True, + help_text=_('Outer dimension of rack (height)') + ) outer_depth = models.PositiveSmallIntegerField( verbose_name=_('outer depth'), blank=True, @@ -140,7 +146,7 @@ class RackType(RackBase): ) clone_fields = ( - 'manufacturer', 'form_factor', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', + 'manufacturer', 'form_factor', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', ) prerequisite_models = ( @@ -173,8 +179,8 @@ class RackType(RackBase): super().clean() # Validate outer dimensions and unit - if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit: - raise ValidationError(_("Must specify a unit when setting an outer width/depth")) + if any([self.outer_width, self.outer_depth, self.outer_height]) and not self.outer_unit: + raise ValidationError(_("Must specify a unit when setting an outer dimension")) # Validate max_weight and weight_unit if self.max_weight and not self.weight_unit: @@ -188,7 +194,7 @@ class RackType(RackBase): self._abs_max_weight = None # Clear unit if outer width & depth are not set - if self.outer_width is None and self.outer_depth is None: + if not any([self.outer_width, self.outer_depth, self.outer_height]): self.outer_unit = None super().save(*args, **kwargs) @@ -235,8 +241,8 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): """ # Fields which cannot be set locally if a RackType is assigned RACKTYPE_FIELDS = ( - 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', - 'outer_unit', 'mounting_depth', 'weight', 'weight_unit', 'max_weight', + 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height', + 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'weight_unit', 'max_weight', ) form_factor = models.CharField( @@ -329,7 +335,8 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): clone_fields = ( 'site', 'location', 'tenant', 'status', 'role', 'form_factor', 'width', 'airflow', 'u_height', 'desc_units', - 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', + 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', + 'weight_unit', ) prerequisite_models = ( 'dcim.Site', @@ -364,8 +371,8 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): raise ValidationError(_("Assigned location must belong to parent site ({site}).").format(site=self.site)) # Validate outer dimensions and unit - if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit: - raise ValidationError(_("Must specify a unit when setting an outer width/depth")) + if any([self.outer_width, self.outer_depth, self.outer_height]) and not self.outer_unit: + raise ValidationError(_("Must specify a unit when setting an outer dimension")) # Validate max_weight and weight_unit if self.max_weight and not self.weight_unit: @@ -374,22 +381,27 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): if not self._state.adding: mounted_devices = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('position') + effective_u_height = self.rack_type.u_height if self.rack_type else self.u_height + effective_starting_unit = self.rack_type.starting_unit if self.rack_type else self.starting_unit + # Validate that Rack is tall enough to house the highest mounted Device if top_device := mounted_devices.last(): - min_height = top_device.position + top_device.device_type.u_height - self.starting_unit - if self.u_height < min_height: + min_height = top_device.position + top_device.device_type.u_height - effective_starting_unit + if effective_u_height < min_height: + field = 'rack_type' if self.rack_type else 'u_height' raise ValidationError({ - 'u_height': _( + field: _( "Rack must be at least {min_height}U tall to house currently installed devices." ).format(min_height=min_height) }) # Validate that the Rack's starting unit is less than or equal to the position of the lowest mounted Device if last_device := mounted_devices.first(): - if self.starting_unit > last_device.position: + if effective_starting_unit > last_device.position: + field = 'rack_type' if self.rack_type else 'starting_unit' raise ValidationError({ - 'starting_unit': _("Rack unit numbering must begin at {position} or less to house " - "currently installed devices.").format(position=last_device.position) + field: _("Rack unit numbering must begin at {position} or less to house " + "currently installed devices.").format(position=last_device.position) }) # Validate that Rack was assigned a Location of its same site, if applicable @@ -409,7 +421,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): self._abs_max_weight = None # Clear unit if outer width & depth are not set - if self.outer_width is None and self.outer_depth is None: + if not any([self.outer_width, self.outer_depth, self.outer_height]): self.outer_unit = None super().save(*args, **kwargs) @@ -720,3 +732,8 @@ class RackReservation(PrimaryModel): @property def unit_list(self): return array_to_string(self.units) + + def to_objectchange(self, action): + objectchange = super().to_objectchange(action) + objectchange.related_object = self.rack + return objectchange diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py index b964421de..8ef6a1d44 100644 --- a/netbox/dcim/search.py +++ b/netbox/dcim/search.py @@ -44,6 +44,7 @@ class DeviceIndex(SearchIndex): ('asset_tag', 50), ('serial', 60), ('name', 100), + ('virtual_chassis', 200), ('description', 500), ('comments', 5000), ) @@ -144,6 +145,7 @@ class LocationIndex(SearchIndex): ('facility', 100), ('slug', 110), ('description', 500), + ('comments', 5000), ) display_attrs = ('site', 'status', 'tenant', 'facility', 'description') @@ -182,6 +184,17 @@ class ModuleBayIndex(SearchIndex): display_attrs = ('device', 'label', 'position', 'description') +@register_search +class ModuleTypeProfileIndex(SearchIndex): + model = models.ModuleTypeProfile + fields = ( + ('name', 100), + ('description', 500), + ('comments', 5000), + ) + display_attrs = ('name', 'description') + + @register_search class ModuleTypeIndex(SearchIndex): model = models.ModuleType @@ -224,7 +237,7 @@ class PowerOutletIndex(SearchIndex): ('label', 200), ('description', 500), ) - display_attrs = ('device', 'label', 'type', 'description') + display_attrs = ('device', 'label', 'type', 'status', 'description') @register_search @@ -317,6 +330,7 @@ class RegionIndex(SearchIndex): ('name', 100), ('slug', 110), ('description', 500), + ('comments', 5000), ) display_attrs = ('parent', 'description') @@ -343,6 +357,7 @@ class SiteGroupIndex(SearchIndex): ('name', 100), ('slug', 110), ('description', 500), + ('comments', 5000), ) display_attrs = ('parent', 'description') diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py index 58fa27c6b..31ec06100 100644 --- a/netbox/dcim/svg/cables.py +++ b/netbox/dcim/svg/cables.py @@ -225,8 +225,7 @@ class CableTraceSVG: """ nodes_height = 0 nodes = [] - # Sort them by name to make renders more readable - for i, term in enumerate(sorted(terminations, key=lambda x: str(x))): + for i, term in enumerate(terminations): node = Node( position=(offset_x + i * width, self.cursor), width=width, diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py index 94dbeeac2..de695664a 100644 --- a/netbox/dcim/svg/racks.py +++ b/netbox/dcim/svg/racks.py @@ -30,10 +30,8 @@ STROKE_RESERVED = '#4d4dff' def get_device_name(device): - if device.virtual_chassis: - name = f'{device.virtual_chassis.name}:{device.vc_position}' - elif device.name: - name = device.name + if device.label: + name = device.label else: name = str(device.device_type) if device.devicebay_count: diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 087132331..d58e4e376 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -59,7 +59,7 @@ MACADDRESS_COPY_BUTTON = """ # class DeviceRoleTable(NetBoxTable): - name = tables.Column( + name = columns.MPTTColumn( verbose_name=_('Name'), linkify=True ) @@ -144,7 +144,7 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): name = tables.TemplateColumn( verbose_name=_('Name'), template_code=DEVICE_LINK, - linkify=True + linkify=True, ) status = columns.ChoiceFieldColumn( verbose_name=_('Status'), @@ -520,6 +520,9 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable): verbose_name=_('Power Port'), linkify=True ) + status = columns.ChoiceFieldColumn( + verbose_name=_('Status'), + ) color = columns.ColorColumn() tags = columns.TagColumn( url_name='dcim:poweroutlet_list' @@ -530,9 +533,11 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable): fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'power_port', 'color', 'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'inventory_items', - 'tags', 'created', 'last_updated', + 'tags', 'created', 'last_updated', 'status', + ) + default_columns = ( + 'pk', 'name', 'device', 'label', 'type', 'status', 'color', 'power_port', 'feed_leg', 'description', ) - default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'power_port', 'feed_leg', 'description') class DevicePowerOutletTable(PowerOutletTable): @@ -550,9 +555,11 @@ class DevicePowerOutletTable(PowerOutletTable): fields = ( 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'color', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', + 'status', ) default_columns = ( - 'pk', 'name', 'label', 'type', 'color', 'power_port', 'feed_leg', 'description', 'cable', 'connection', + 'pk', 'name', 'label', 'type', 'status', 'color', 'power_port', 'feed_leg', 'description', 'cable', + 'connection', ) @@ -671,7 +678,7 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', - 'qinq_svlan', 'inventory_items', 'created', 'last_updated', + 'qinq_svlan', 'inventory_items', 'created', 'last_updated', 'vlan_translation_policy' ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') @@ -705,7 +712,7 @@ class DeviceInterfaceTable(InterfaceTable): model = models.Interface fields = ( 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag', - 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'mgmt_only', 'mtu', 'mode', 'primary_mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'actions', diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index a7f8f08e8..91f9f3b47 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -31,6 +31,11 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable): verbose_name=_('Name'), linkify=True ) + racktype_count = columns.LinkedCountColumn( + viewname='dcim:racktype_list', + url_params={'manufacturer_id': 'pk'}, + verbose_name=_('Rack Types') + ) devicetype_count = columns.LinkedCountColumn( viewname='dcim:devicetype_list', url_params={'manufacturer_id': 'pk'}, @@ -58,12 +63,12 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = models.Manufacturer fields = ( - 'pk', 'id', 'name', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'platform_count', - 'description', 'slug', 'tags', 'contacts', 'actions', 'created', 'last_updated', + 'pk', 'id', 'name', 'racktype_count', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', + 'platform_count', 'description', 'slug', 'tags', 'contacts', 'actions', 'created', 'last_updated', ) default_columns = ( - 'pk', 'name', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'platform_count', - 'description', 'slug', + 'pk', 'name', 'racktype_count', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', + 'platform_count', 'description', 'slug', ) diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py index 6bd0d53b5..52edea8b4 100644 --- a/netbox/dcim/tables/modules.py +++ b/netbox/dcim/tables/modules.py @@ -1,25 +1,64 @@ from django.utils.translation import gettext_lazy as _ import django_tables2 as tables -from dcim.models import Module, ModuleType +from dcim.models import Module, ModuleType, ModuleTypeProfile from netbox.tables import NetBoxTable, columns -from .template_code import WEIGHT +from .template_code import MODULETYPEPROFILE_ATTRIBUTES, WEIGHT __all__ = ( 'ModuleTable', + 'ModuleTypeProfileTable', 'ModuleTypeTable', ) +class ModuleTypeProfileTable(NetBoxTable): + name = tables.Column( + verbose_name=_('Name'), + linkify=True + ) + attributes = columns.TemplateColumn( + template_code=MODULETYPEPROFILE_ATTRIBUTES, + accessor=tables.A('schema__properties'), + orderable=False, + verbose_name=_('Attributes') + ) + comments = columns.MarkdownColumn( + verbose_name=_('Comments'), + ) + tags = columns.TagColumn( + url_name='dcim:moduletypeprofile_list' + ) + + class Meta(NetBoxTable.Meta): + model = ModuleTypeProfile + fields = ( + 'pk', 'id', 'name', 'description', 'comments', 'tags', 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'name', 'description', 'attributes', + ) + + class ModuleTypeTable(NetBoxTable): - model = tables.Column( - linkify=True, - verbose_name=_('Module Type') + profile = tables.Column( + verbose_name=_('Profile'), + linkify=True ) manufacturer = tables.Column( verbose_name=_('Manufacturer'), linkify=True ) + model = tables.Column( + linkify=True, + verbose_name=_('Module Type') + ) + weight = columns.TemplateColumn( + verbose_name=_('Weight'), + template_code=WEIGHT, + order_by=('_abs_weight', 'weight_unit') + ) + attributes = columns.DictColumn() instance_count = columns.LinkedCountColumn( viewname='dcim:module_list', url_params={'module_type_id': 'pk'}, @@ -31,20 +70,15 @@ class ModuleTypeTable(NetBoxTable): tags = columns.TagColumn( url_name='dcim:moduletype_list' ) - weight = columns.TemplateColumn( - verbose_name=_('Weight'), - template_code=WEIGHT, - order_by=('_abs_weight', 'weight_unit') - ) class Meta(NetBoxTable.Meta): model = ModuleType fields = ( - 'pk', 'id', 'model', 'manufacturer', 'part_number', 'airflow', 'weight', 'description', 'comments', 'tags', - 'created', 'last_updated', + 'pk', 'id', 'model', 'profile', 'manufacturer', 'part_number', 'airflow', 'weight', 'description', + 'attributes', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( - 'pk', 'model', 'manufacturer', 'part_number', + 'pk', 'model', 'profile', 'manufacturer', 'part_number', ) diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index dbd99ca24..ee40056de 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -5,7 +5,7 @@ from django_tables2.utils import Accessor from dcim.models import Rack, RackReservation, RackRole, RackType from netbox.tables import NetBoxTable, columns from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin -from .template_code import WEIGHT +from .template_code import OUTER_UNIT, WEIGHT __all__ = ( 'RackTable', @@ -62,12 +62,16 @@ class RackTypeTable(NetBoxTable): template_code="{{ value }}U", verbose_name=_('Height') ) - outer_width = tables.TemplateColumn( - template_code="{{ record.outer_width }} {{ record.outer_unit }}", + outer_width = columns.TemplateColumn( + template_code=OUTER_UNIT, verbose_name=_('Outer Width') ) - outer_depth = tables.TemplateColumn( - template_code="{{ record.outer_depth }} {{ record.outer_unit }}", + outer_height = columns.TemplateColumn( + template_code=OUTER_UNIT, + verbose_name=_('Outer Height') + ) + outer_depth = columns.TemplateColumn( + template_code=OUTER_UNIT, verbose_name=_('Outer Depth') ) weight = columns.TemplateColumn( @@ -96,8 +100,8 @@ class RackTypeTable(NetBoxTable): model = RackType fields = ( 'pk', 'id', 'model', 'manufacturer', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width', - 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'description', 'comments', - 'instance_count', 'tags', 'created', 'last_updated', + 'outer_height', 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'description', + 'comments', 'instance_count', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'model', 'manufacturer', 'type', 'u_height', 'description', 'instance_count', @@ -159,12 +163,16 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): tags = columns.TagColumn( url_name='dcim:rack_list' ) - outer_width = tables.TemplateColumn( - template_code="{{ record.outer_width }} {{ record.outer_unit }}", + outer_width = columns.TemplateColumn( + template_code=OUTER_UNIT, verbose_name=_('Outer Width') ) - outer_depth = tables.TemplateColumn( - template_code="{{ record.outer_depth }} {{ record.outer_unit }}", + outer_height = columns.TemplateColumn( + template_code=OUTER_UNIT, + verbose_name=_('Outer Height') + ) + outer_depth = columns.TemplateColumn( + template_code=OUTER_UNIT, verbose_name=_('Outer Depth') ) weight = columns.TemplateColumn( @@ -183,8 +191,9 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): fields = ( 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'rack_type', 'serial', 'asset_tag', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width', - 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'comments', 'device_count', - 'get_utilization', 'get_power_utilization', 'description', 'contacts', 'tags', 'created', 'last_updated', + 'outer_height', 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'comments', + 'device_count', 'get_utilization', 'get_power_utilization', 'description', 'contacts', + 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'rack_type', 'u_height', diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 77844f086..0c9494d51 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -32,12 +32,15 @@ class RegionTable(ContactsColumnMixin, NetBoxTable): tags = columns.TagColumn( url_name='dcim:region_list' ) + comments = columns.MarkdownColumn( + verbose_name=_('Comments'), + ) class Meta(NetBoxTable.Meta): model = Region fields = ( - 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'created', 'last_updated', - 'actions', + 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags', + 'created', 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'site_count', 'description') @@ -59,12 +62,15 @@ class SiteGroupTable(ContactsColumnMixin, NetBoxTable): tags = columns.TagColumn( url_name='dcim:sitegroup_list' ) + comments = columns.MarkdownColumn( + verbose_name=_('Comments'), + ) class Meta(NetBoxTable.Meta): model = SiteGroup fields = ( - 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'created', 'last_updated', - 'actions', + 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags', + 'created', 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'site_count', 'description') @@ -94,7 +100,6 @@ class SiteTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): verbose_name=_('ASNs') ) asn_count = columns.LinkedCountColumn( - accessor=tables.A('asns__count'), viewname='ipam:asn_list', url_params={'site_id': 'pk'}, verbose_name=_('ASN Count') @@ -147,19 +152,29 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): url_params={'location_id': 'pk'}, verbose_name=_('Devices') ) + vlangroup_count = columns.LinkedCountColumn( + viewname='ipam:vlangroup_list', + url_params={'location': 'pk'}, + verbose_name=_('VLAN Groups') + ) tags = columns.TagColumn( url_name='dcim:location_list' ) actions = columns.ActionsColumn( extra_buttons=LOCATION_BUTTONS ) + comments = columns.MarkdownColumn( + verbose_name=_('Comments'), + ) class Meta(NetBoxTable.Meta): model = Location fields = ( 'pk', 'id', 'name', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count', 'device_count', - 'description', 'slug', 'contacts', 'tags', 'actions', 'created', 'last_updated', + 'description', 'slug', 'comments', 'contacts', 'tags', 'actions', 'created', 'last_updated', + 'vlangroup_count', ) default_columns = ( - 'pk', 'name', 'site', 'status', 'facility', 'tenant', 'rack_count', 'device_count', 'description' + 'pk', 'name', 'site', 'status', 'facility', 'tenant', 'rack_count', 'device_count', 'vlangroup_count', + 'description' ) diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 449d55e14..356f76750 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -35,7 +35,7 @@ WEIGHT = """ """ DEVICE_LINK = """ -{{ value|default:'Unnamed device' }} +{{ record.label|default:'Unnamed device' }} """ DEVICEBAY_STATUS = """ @@ -64,21 +64,23 @@ INTERFACE_IPADDRESSES = """ INTERFACE_FHRPGROUPS = """ {% for assignment in value.all %} - {{ assignment.group.get_protocol_display }}: {{ assignment.group.group_id }} + {{ assignment.group }} {% endfor %} """ INTERFACE_TAGGED_VLANS = """ -{% if record.mode == 'tagged' %} +{% load i18n %} +{% if record.mode == 'access' %} +{% elif record.mode == 'tagged-all' %} + {% trans "All" %} +{% else %} {% if value.count > 3 %} {{ value.count }} VLANs {% else %} {% for vlan in value.all %} - {{ vlan }}
+ {{ vlan }}
{% endfor %} {% endif %} -{% elif record.mode == 'tagged-all' %} - All {% endif %} """ @@ -107,6 +109,11 @@ LOCATION_BUTTONS = """ """ +OUTER_UNIT = """ +{% load helpers %} +{% if value %}{{ value }} {{ record.outer_unit }}{% endif %} +""" + # # Device component templatebuttons # @@ -157,8 +164,8 @@ CONSOLEPORT_BUTTONS = """ {% endif %} {% elif perms.dcim.add_cable %} - - + +