diff --git a/.github/ISSUE_TEMPLATE/01-feature_request.yaml b/.github/ISSUE_TEMPLATE/01-feature_request.yaml index 82ba01c77..300d7ce6a 100644 --- a/.github/ISSUE_TEMPLATE/01-feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/01-feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v4.1.1 + placeholder: v4.1.6 validations: required: true - type: dropdown @@ -24,6 +24,20 @@ body: - Data model extension - New functionality - Change to existing functionality + - 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 diff --git a/.github/ISSUE_TEMPLATE/02-bug_report.yaml b/.github/ISSUE_TEMPLATE/02-bug_report.yaml index 2476bbcfc..2b782a6cd 100644 --- a/.github/ISSUE_TEMPLATE/02-bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/02-bug_report.yaml @@ -22,11 +22,24 @@ 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.1 + placeholder: v4.1.6 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 2ad52023e..efbf38932 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -7,6 +7,9 @@ contact_links: - name: ❓ Discussion url: https://github.com/netbox-community/netbox/discussions about: "If you're just looking for help, try starting a discussion instead." + - name: 👔 Professional Support + url: https://netboxlabs.com/netbox-enterprise/ + about: "Professional support is available for NetBox Enterprise or Cloud." - name: 🌎 Correct a Translation url: https://explore.transifex.com/netbox-community/netbox/ about: "Spot an incorrect translation? You can propose a fix on Transifex." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4be03742..c3b4876c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,7 +73,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt - pip install pycodestyle coverage tblib + pip install ruff coverage tblib - name: Build documentation run: mkdocs build @@ -85,7 +85,7 @@ jobs: run: python netbox/manage.py makemigrations --check - name: Check PEP8 compliance - run: pycodestyle --ignore=W504,E501 --exclude=node_modules netbox/ + run: ruff check netbox/ - name: Check UI ESLint, TypeScript, and Prettier Compliance run: yarn --cwd netbox/project-static validate diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..09f935b61 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,44 @@ +repos: +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.9 + hooks: + - id: ruff + name: "Ruff linter" + args: [ netbox/ ] +- repo: local + hooks: + - id: django-check + name: "Django system check" + description: "Run Django's internal check for common problems" + entry: python netbox/manage.py check + language: system + pass_filenames: false + types: [python] + - id: django-makemigrations + name: "Django migrations check" + description: "Check for any missing Django migrations" + entry: python netbox/manage.py makemigrations --check + language: system + pass_filenames: false + types: [python] + - id: mkdocs-build + name: "Build documentation" + description: "Build the documentation with mkdocs" + files: 'docs/' + entry: mkdocs build + language: system + pass_filenames: false + - id: yarn-validate + name: "Yarn validate" + description: "Check UI ESLint, TypeScript, and Prettier compliance" + files: 'netbox/project-static/' + entry: yarn --cwd netbox/project-static validate + language: system + pass_filenames: false + - id: verify-bundles + name: "Verify static asset bundles" + description: "Ensure that any modified static assets have been compiled" + files: 'netbox/project-static/' + entry: scripts/verify-bundles.sh + language: system + pass_filenames: false diff --git a/README.md b/README.md index 34975980b..e3829c2cc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@
- NetBox logo + NetBox logo

The cornerstone of every automated network

Latest release License @@ -7,7 +7,11 @@ GitHub stars Languages supported CI status -

+

+ NetBox Community | + NetBox Cloud | + NetBox Enterprise +

NetBox exists to empower network engineers. Since its release in 2016, it has become the go-to solution for modeling and documenting network infrastructure for thousands of organizations worldwide. As a successor to legacy IPAM and DCIM applications, NetBox provides a cohesive, extensive, and accessible data model for all things networked. By providing a single robust user interface and programmable APIs for everything from cable maps to device configurations, NetBox serves as the central source of truth for the modern network. @@ -81,11 +85,6 @@ NetBox automatically logs the creation, modification, and deletion of all manage * The [official documentation](https://docs.netbox.dev) offers a comprehensive introduction. * Check out [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions) for even more projects to get the most out of NetBox! -

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

- ## Get Involved * Follow [@NetBoxOfficial](https://twitter.com/NetBoxOfficial) on Twitter! diff --git a/base_requirements.txt b/base_requirements.txt index 76955a6e1..3e5bcb0db 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -116,6 +116,10 @@ PyYAML # https://github.com/psf/requests/blob/main/HISTORY.md requests +# rq +# https://github.com/rq/rq/blob/master/CHANGES.md +rq + # Social authentication framework # https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md social-auth-core diff --git a/contrib/generated_schema.json b/contrib/generated_schema.json index 835a6d829..56ddee50e 100644 --- a/contrib/generated_schema.json +++ b/contrib/generated_schema.json @@ -12,6 +12,9 @@ "left-to-right", "right-to-left", "side-to-rear", + "rear-to-side", + "bottom-to-top", + "top-to-bottom", "passive", "mixed" ] @@ -290,6 +293,7 @@ "molex-micro-fit-2x2", "molex-micro-fit-2x4", "dc-terminal", + "eaton-c39", "hdot-cx", "saf-d-grid", "neutrik-powercon-20a", @@ -330,6 +334,7 @@ "5gbase-t", "10gbase-t", "10gbase-cx4", + "100base-x-sfp", "1000base-x-gbic", "1000base-x-sfp", "10gbase-x-sfpp", @@ -381,6 +386,7 @@ "ieee802.11ay", "ieee802.11be", "ieee802.15.1", + "ieee802.15.4", "other-wireless", "gsm", "cdma", diff --git a/docs/administration/authentication/google.md b/docs/administration/authentication/google.md new file mode 100644 index 000000000..456f3a457 --- /dev/null +++ b/docs/administration/authentication/google.md @@ -0,0 +1,52 @@ +# Google + +This guide explains how to configure single sign-on (SSO) support for NetBox using [Google OAuth2](https://developers.google.com/identity/protocols/oauth2/web-server) as an authentication backend. + +## Google OAuth2 Configuration + +1. Log into [console.cloud.google.com](https://console.cloud.google.com/). +2. Create new project for NetBox. +3. Under "APIs and Services" click "OAuth consent screen" and enter the required information. +4. Under "Credentials," click "Create Credentials" and select "OAuth 2.0 Client ID." Select type "Web application." + - "Authorized JavaScript origins" should follow the format `http[s]://[:]` + - "Authorized redirect URIs" should follow the format `http[s]://[:]/oauth/complete/google-oauth2/` +5. Copy the "Client ID" and "Client Secret" values somewhere convenient. + +!!! note + Google requires the NetBox hostname to use a public top-level-domain (e.g. `.com`, `.net`). The use of IP addresses is not permitted (except `127.0.0.1`). + +For more information, consult [Google's documentation](https://developers.google.com/identity/protocols/oauth2/web-server#prerequisites). + +## NetBox Configuration + +### 1. Enter configuration parameters + +Enter the following configuration parameters in `configuration.py`, substituting your own values: + +```python +REMOTE_AUTH_BACKEND = 'social_core.backends.google.GoogleOAuth2' +SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = '{CLIENT_ID}' +SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = '{CLIENT_SECRET}' +``` + +### 2. Restart NetBox + +Restart the NetBox services so that the new configuration takes effect. This is typically done with the command below: + +```no-highlight +sudo systemctl restart netbox +``` + +## Testing + +Log out of NetBox if already authenticated, and click the "Log In" button at top right. You should see the normal login form as well as an option to authenticate using Google. Click that link. + +![NetBox Google login form](../../media/authentication/netbox_google_login.png) + +You should be redirected to Google's authentication portal. Enter the username/email and password of your test account to continue. You may also be prompted to grant this application access to your account. + +![NetBox Google login form](../../media/authentication/google_login_portal.png) + +If successful, you will be redirected back to the NetBox UI, and will be logged in as the Google user. You can verify this by navigating to your profile (using the button at top right). + +This user account has been replicated locally to NetBox, and can now be assigned groups and permissions. diff --git a/docs/administration/authentication/microsoft-azure-ad.md b/docs/administration/authentication/microsoft-entra-id.md similarity index 95% rename from docs/administration/authentication/microsoft-azure-ad.md rename to docs/administration/authentication/microsoft-entra-id.md index 17b130818..b44499fbe 100644 --- a/docs/administration/authentication/microsoft-azure-ad.md +++ b/docs/administration/authentication/microsoft-entra-id.md @@ -1,8 +1,8 @@ -# Microsoft Azure AD +# Microsoft Entra ID -This guide explains how to configure single sign-on (SSO) support for NetBox using [Microsoft Azure Active Directory (AD)](https://azure.microsoft.com/en-us/services/active-directory/) as an authentication backend. +This guide explains how to configure single sign-on (SSO) support for NetBox using [Microsoft Entra ID](https://www.microsoft.com/en-us/security/business/identity-access/microsoft-entra-id) as an authentication backend. -## Azure AD Configuration +## Entra ID Configuration ### 1. Create a test user (optional) diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index 3fa6491d2..1051b31f6 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -72,6 +72,9 @@ script_order = (MyCustomScript, AnotherCustomScript) Script attributes are defined under a class named `Meta` within the script. These are optional, but encouraged. +!!! warning + These are also defined and used as properties on the base custom script class, so don't use the same names as variables or override them in your custom script. + ### `name` This is the human-friendly names of your script. If omitted, the class name will be used. diff --git a/docs/development/getting-started.md b/docs/development/getting-started.md index 4dbdb63b2..6e425d5a3 100644 --- a/docs/development/getting-started.md +++ b/docs/development/getting-started.md @@ -62,22 +62,7 @@ $issue-$description The description should be just two or three words to imply the focus of the work being performed. For example, bug #1234 to fix a TypeError exception when creating a device might be named `1234-device-typerror`. This ensures that branches are always follow some logical ordering (e.g. when running `git branch -a`) and helps other developers quickly identify the purpose of each. -### 3. Enable Pre-Commit Hooks - -NetBox ships with a [git pre-commit hook](https://githooks.com/) script that automatically checks for style compliance and missing database migrations prior to committing changes. This helps avoid erroneous commits that result in CI test failures. You are encouraged to enable it by creating a link to `scripts/git-hooks/pre-commit`: - -```no-highlight -cd .git/hooks/ -ln -s ../../scripts/git-hooks/pre-commit -``` -For the pre-commit hooks to work, you will also need to install the pycodestyle package: - -```no-highlight -python -m pip install pycodestyle -``` -...and set up the yarn packages as shown in the [Web UI Development Guide](web-ui.md) - -### 4. Create a Python Virtual Environment +### 3. Create a Python Virtual Environment A [virtual environment](https://docs.python.org/3/tutorial/venv.html) (or "venv" for short) is like a container for a set of Python packages. These allow you to build environments suited to specific projects without interfering with system packages or other projects. When installed per the documentation, NetBox uses a virtual environment in production. @@ -101,7 +86,7 @@ source ~/.venv/netbox/bin/activate Notice that the console prompt changes to indicate the active environment. This updates the necessary system environment variables to ensure that any Python scripts are run within the virtual environment. -### 5. Install Required Packages +### 4. Install Required Packages With the virtual environment activated, install the project's required Python packages using the `pip` module. Required packages are defined in `requirements.txt`. Each line in this file specifies the name and specific version of a required package. @@ -109,6 +94,26 @@ With the virtual environment activated, install the project's required Python pa python -m pip install -r requirements.txt ``` +### 5. Install Pre-Commit + +NetBox uses [`pre-commit`](https://pre-commit.com/) to automatically validate code when commiting new changes. This includes the following operations: + +* Run the `ruff` Python linter +* Run Django's internal system check +* Check for missing database migrations +* Validate any changes to the documentation with `mkdocs` +* Validate Typescript & Sass styling with `yarn` +* Ensure that any modified static front end assets have been recompiled + +Enable `pre-commit` with the following commands _prior_ to commiting any changes: + +```no-highlight +python -m pip install ruff pre-commit +pre-commit install +``` + +You may also need to set up the yarn packages as shown in the [Web UI Development Guide](web-ui.md). + ### 6. Configure NetBox Within the `netbox/netbox/` directory, copy `configuration_example.py` to `configuration.py` and update the following parameters: diff --git a/docs/development/style-guide.md b/docs/development/style-guide.md index 9e5606749..9d6630de0 100644 --- a/docs/development/style-guide.md +++ b/docs/development/style-guide.md @@ -1,6 +1,6 @@ # Style Guide -NetBox generally follows the [Django style guide](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/coding-style/), which is itself based on [PEP 8](https://www.python.org/dev/peps/pep-0008/). [Pycodestyle](https://github.com/pycqa/pycodestyle) is used to validate code formatting, ignoring certain violations. +NetBox generally follows the [Django style guide](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/coding-style/), which is itself based on [PEP 8](https://www.python.org/dev/peps/pep-0008/). [ruff](https://docs.astral.sh/ruff/) is used for linting (with certain [exceptions](#linter-exceptions)). ## Code @@ -20,32 +20,32 @@ NetBox generally follows the [Django style guide](https://docs.djangoproject.com * Nested API serializers generate minimal representations of an object. These are stored separately from the primary serializers to avoid circular dependencies. Always import nested serializers from other apps directly. For example, from within the DCIM app you would write `from ipam.api.nested_serializers import NestedIPAddressSerializer`. -### PEP 8 Exceptions +### Linting -NetBox ignores certain PEP8 assertions. These are listed below. +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: -#### Wildcard Imports +``` +ruff check netbox/ +``` + +#### Linter Exceptions + +The following rules are ignored when linting. + +##### [E501](https://docs.astral.sh/ruff/rules/line-too-long/): Line too long + +NetBox does not enforce a hard restriction on line length, although a maximum length of 120 characters is strongly encouraged for Python code where possible. The maximum length does not apply to HTML templates or to automatically generated code (e.g. database migrations). + +##### [F403](https://docs.astral.sh/ruff/rules/undefined-local-with-import-star/): Undefined local with import star Wildcard imports (for example, `from .constants import *`) are acceptable under any of the following conditions: * The library being import contains only constant declarations (e.g. `constants.py`) * The library being imported explicitly defines `__all__` -#### Maximum Line Length (E501) +##### [F405](https://docs.astral.sh/ruff/rules/undefined-local-with-import-star-usage/): Undefined local with import star usage -NetBox does not restrict lines to a maximum length of 79 characters. We use a maximum line length of 120 characters, however this is not enforced by CI. The maximum length does not apply to HTML templates or to automatically generated code (e.g. database migrations). - -#### Line Breaks Following Binary Operators (W504) - -Line breaks are permitted following binary operators. - -### Enforcing Code Style - -The [`pycodestyle`](https://pypi.org/project/pycodestyle/) utility (formerly `pep8`) is used by the CI process 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 `pycodestyle` manually, run: - -``` -pycodestyle --ignore=W504,E501 netbox/ -``` +The justification for ignoring this rule is the same as F403 above. ### Introducing New Dependencies @@ -76,4 +76,4 @@ When adding a new dependency, a short description of the package and the URL of * When referring to NetBox in writing, use the proper form "NetBox," with the letters N and B capitalized. The lowercase form "netbox" should be used in code, filenames, etc. but never "Netbox" or any other deviation. -* There is an SVG form of the NetBox logo at [docs/netbox_logo.svg](../netbox_logo.svg). It is preferred to use this logo for all purposes as it scales to arbitrary sizes without loss of resolution. If a raster image is required, the SVG logo should be converted to a PNG image of the prescribed size. +* There are SVG forms of the NetBox logo for both [light mode](../netbox_logo_light.svg) and [dark mode](../netbox_logo_dark.svg) available. It is preferred to use the SVG logo for all purposes as it scales to arbitrary sizes without loss of resolution. If a raster image is required, the SVG logo should be converted to a PNG image of the desired size. diff --git a/docs/extra.css b/docs/extra.css index e953fa14c..4b8cd87fe 100644 --- a/docs/extra.css +++ b/docs/extra.css @@ -5,6 +5,10 @@ img { margin-right: auto; } +.md-content img { + background-color: rgba(255, 255, 255, 0.64); +} + /* Tables */ table { margin-bottom: 24px; diff --git a/docs/features/authentication-permissions.md b/docs/features/authentication-permissions.md index 14e13d5cd..cf3d11ef3 100644 --- a/docs/features/authentication-permissions.md +++ b/docs/features/authentication-permissions.md @@ -41,7 +41,7 @@ NetBox integrates with the open source [python-social-auth](https://github.com/p * Google * Hashicorp Vault * Keycloak -* Microsoft Azure AD +* Microsoft Entra ID * Microsoft Graph * Okta * OIDC diff --git a/docs/features/synchronized-data.md b/docs/features/synchronized-data.md index 8c95c8779..23c79feed 100644 --- a/docs/features/synchronized-data.md +++ b/docs/features/synchronized-data.md @@ -13,6 +13,9 @@ To enable remote data synchronization, the NetBox administrator first designates !!! info Data backends which connect to external sources typically require the installation of one or more supporting Python libraries. The Git backend requires the [`dulwich`](https://www.dulwich.io/) package, and the S3 backend requires the [`boto3`](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html) package. These must be installed within NetBox's environment to enable these backends. +!!! info + If you are configuring Git and have `HTTP_PROXIES` configured to use the SOCKS protocol, you will also need to install the [`python_socks`](https://pypi.org/project/python-socks/) Python library. + Each type of remote source has its own configuration parameters. For instance, a git source will ask the user to specify a branch and authentication credentials. Once the source has been created, a synchronization job is run to automatically replicate remote files in the local database. The following NetBox models can be associated with replicated data files: diff --git a/docs/index.md b/docs/index.md index 5ef650ca6..a79ab03b4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,4 +1,5 @@ -![NetBox](netbox_logo.svg "NetBox logo"){style="height: 100px; margin-bottom: 3em"} +![NetBox](netbox_logo_light.svg#only-light "NetBox logo"){style="height: 100px; margin-bottom: 3em; background: none;"} +![NetBox](netbox_logo_dark.svg#only-dark "NetBox logo"){style="height: 100px; margin-bottom: 3em; background: none;"} # The Premier Network Source of Truth diff --git a/docs/media/authentication/google_login_portal.png b/docs/media/authentication/google_login_portal.png new file mode 100644 index 000000000..55eefd872 Binary files /dev/null and b/docs/media/authentication/google_login_portal.png differ diff --git a/docs/media/authentication/netbox_google_login.png b/docs/media/authentication/netbox_google_login.png new file mode 100644 index 000000000..730173b5d Binary files /dev/null and b/docs/media/authentication/netbox_google_login.png differ diff --git a/docs/media/misc/netbox_cloud.png b/docs/media/misc/netbox_cloud.png deleted file mode 100644 index f9deca674..000000000 Binary files a/docs/media/misc/netbox_cloud.png and /dev/null differ diff --git a/docs/media/misc/netbox_logo.png b/docs/media/misc/netbox_logo.png deleted file mode 100644 index c6e0a58e6..000000000 Binary files a/docs/media/misc/netbox_logo.png and /dev/null differ diff --git a/docs/models/ipam/asn.md b/docs/models/ipam/asn.md index 8de3cfd93..17fa5ebe3 100644 --- a/docs/models/ipam/asn.md +++ b/docs/models/ipam/asn.md @@ -1,6 +1,6 @@ # ASNs -An Autonomous System Number (ASN) is a numeric identifier used in the BGP protocol to identify which [autonomous system](https://en.wikipedia.org/wiki/Autonomous_system_%28Internet%29) a particular prefix is originating and transiting through. NetBox support both 32- and 64- ASNs. +An Autonomous System Number (ASN) is a numeric identifier used in the Border Gateway Protocol (BGP) to identify which [autonomous system](https://en.wikipedia.org/wiki/Autonomous_system_%28Internet%29) a particular prefix is originating from or transiting through. NetBox supports both 16- and 32-bit ASNs. ASNs must be globally unique within NetBox, and may be allocated from within a [defined range](./asnrange.md). Each ASN may be assigned to multiple [sites](../dcim/site.md). @@ -8,7 +8,7 @@ ASNs must be globally unique within NetBox, and may be allocated from within a [ ### AS Number -The 32- or 64-bit AS number. +The 16- or 32-bit AS number. ### RIR diff --git a/docs/models/vpn/l2vpn.md b/docs/models/vpn/l2vpn.md index 79b7435bf..1167c1c17 100644 --- a/docs/models/vpn/l2vpn.md +++ b/docs/models/vpn/l2vpn.md @@ -28,6 +28,7 @@ The technology employed in forming and operating the L2VPN. Choices include: * VXLAN-EVPN * MPLS-EVPN * PBB-EVPN +* EVPN-VPWS !!! note Designating the type as VPWS, EPL, EP-LAN, EP-TREE will limit the L2VPN instance to two terminations. diff --git a/docs/netbox_logo_dark.svg b/docs/netbox_logo_dark.svg new file mode 100644 index 000000000..958a1d401 --- /dev/null +++ b/docs/netbox_logo_dark.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/netbox_logo.svg b/docs/netbox_logo_light.svg similarity index 100% rename from docs/netbox_logo.svg rename to docs/netbox_logo_light.svg diff --git a/docs/release-notes/version-4.1.md b/docs/release-notes/version-4.1.md index 5eccef588..7b4e18919 100644 --- a/docs/release-notes/version-4.1.md +++ b/docs/release-notes/version-4.1.md @@ -1,5 +1,113 @@ # NetBox v4.1 +## v4.1.6 (2024-10-31) + +### Bug Fixes + +* [#17700](https://github.com/netbox-community/netbox/issues/17700) - Fix warning when no scripts are found within a script module +* [#17884](https://github.com/netbox-community/netbox/issues/17884) - Fix translation support for certain tab headings +* [#17885](https://github.com/netbox-community/netbox/issues/17885) - Fix regression preventing custom scripts from executing + +## v4.1.5 (2024-10-28) + +### Enhancements + +* [#17789](https://github.com/netbox-community/netbox/issues/17789) - Provide a single "scope" field for bulk editing VLAN group scope assignments + +### Bug Fixes + +* [#17358](https://github.com/netbox-community/netbox/issues/17358) - Fix validation of overlapping IP ranges +* [#17374](https://github.com/netbox-community/netbox/issues/17374) - Fix styling of highlighted table rows in dark mode +* [#17460](https://github.com/netbox-community/netbox/issues/17460) - Ensure bulk action buttons are consistent for device type components +* [#17635](https://github.com/netbox-community/netbox/issues/17635) - Ensure AbortTransaction is caught when running a custom script with `commit=False` +* [#17685](https://github.com/netbox-community/netbox/issues/17685) - Ensure background jobs are validated before being scheduled +* [#17710](https://github.com/netbox-community/netbox/issues/17710) - Remove cached fields on CableTermination model from GraphQL API +* [#17740](https://github.com/netbox-community/netbox/issues/17740) - Ensure support for image attachments with a `.webp` file extension +* [#17749](https://github.com/netbox-community/netbox/issues/17749) - Restore missing `devicetypes` and `children` fields for several objects in GraphQL API +* [#17754](https://github.com/netbox-community/netbox/issues/17754) - Remove paginator from version history table under plugin view +* [#17759](https://github.com/netbox-community/netbox/issues/17759) - Retain `job_timeout` value when scheduling a recurring custom script +* [#17774](https://github.com/netbox-community/netbox/issues/17774) - Fix SSO login support for Entra ID (formerly Azure AD) +* [#17802](https://github.com/netbox-community/netbox/issues/17802) - Fix background color for bulk rename buttons in list views +* [#17838](https://github.com/netbox-community/netbox/issues/17838) - Adjust `manage.py` to reference `python3` executable + +--- + +## v4.1.4 (2024-10-15) + +### Enhancements + +* [#11671](https://github.com/netbox-community/netbox/issues/11671) - Display device's rack position in cable traces +* [#15829](https://github.com/netbox-community/netbox/issues/15829) - Rename Microsoft Azure AD SSO backend to Microsoft Entra ID +* [#16009](https://github.com/netbox-community/netbox/issues/16009) - Float form & bulk operation buttons within UI +* [#17079](https://github.com/netbox-community/netbox/issues/17079) - Introduce additional choices for device airflow direction +* [#17216](https://github.com/netbox-community/netbox/issues/17216) - Add EVPN-VPWS L2VPN type +* [#17655](https://github.com/netbox-community/netbox/issues/17655) - Limit the display of tagged VLANs within interface tables +* [#17669](https://github.com/netbox-community/netbox/issues/17669) - Enable filtering VLANs by assigned device or VM interface + +### Bug Fixes + +* [#16024](https://github.com/netbox-community/netbox/issues/16024) - Fix AND/OR filtering in GraphQL API for selection fields +* [#17400](https://github.com/netbox-community/netbox/issues/17400) - Fix cable tracing across split paths +* [#17562](https://github.com/netbox-community/netbox/issues/17562) - Fix GraphQL API query support for custom field choices +* [#17566](https://github.com/netbox-community/netbox/issues/17566) - Fix AttributeError exception resulting from background jobs with no associated object type +* [#17614](https://github.com/netbox-community/netbox/issues/17614) - Disallow removal of a master device from its virtual chassis +* [#17636](https://github.com/netbox-community/netbox/issues/17636) - Fix filtering of related objects when adding a power port, rear port, or inventory item template to a device type +* [#17644](https://github.com/netbox-community/netbox/issues/17644) - Correct sizing of logo & SSO icons on login page +* [#17648](https://github.com/netbox-community/netbox/issues/17648) - Fix AttributeError exception when attempting to delete a background job under certain conditions +* [#17663](https://github.com/netbox-community/netbox/issues/17663) - Fix extended lookups for choice field filters +* [#17671](https://github.com/netbox-community/netbox/issues/17671) - Fix the display of rack types in global search results +* [#17713](https://github.com/netbox-community/netbox/issues/17713) - Fix UnboundLocalError exception when attempting to sync data source in parallel + +--- + +## v4.1.3 (2024-10-02) + +### Enhancements + +* [#17639](https://github.com/netbox-community/netbox/issues/17639) - Add SOCKS support to proxy settings for Git remote data sources + +### Bug Fixes + +* [#17558](https://github.com/netbox-community/netbox/issues/17558) - Raise validation error when attempting to remove a custom field choice in use + +--- + +## v4.1.2 (2024-09-26) + +### Enhancements + +* [#14201](https://github.com/netbox-community/netbox/issues/14201) - Enable global search for AS numbers using "AS" prefix +* [#15408](https://github.com/netbox-community/netbox/issues/15408) - Enable bulk import of primary IPv4 & IPv6 addresses for virtual device contexts (VDCs) +* [#16781](https://github.com/netbox-community/netbox/issues/16781) - Add 100Base-X SFP interface type +* [#17255](https://github.com/netbox-community/netbox/issues/17255) - Include return URL when creating new IP address from prefix IPs list +* [#17471](https://github.com/netbox-community/netbox/issues/17471) - Add Eaton C39 power outlet type +* [#17482](https://github.com/netbox-community/netbox/issues/17482) - Do not preload Branch & StagedChange models in `nbshell` +* [#17550](https://github.com/netbox-community/netbox/issues/17550) - Add IEEE 802.15.4 wireless interface type + +### Bug Fixes + +* [#16837](https://github.com/netbox-community/netbox/issues/16837) - Fix filtering of cables with no type assigned +* [#17083](https://github.com/netbox-community/netbox/issues/17083) - Trim clickable area of form field labels +* [#17126](https://github.com/netbox-community/netbox/issues/17126) - Show total device weight in both imperial & metric units +* [#17360](https://github.com/netbox-community/netbox/issues/17360) - Fix AttributeError under child object views when experimental HTMX navigation is enabled +* [#17406](https://github.com/netbox-community/netbox/issues/17406) - Fix the cleanup of stale custom field data after removing a plugin +* [#17419](https://github.com/netbox-community/netbox/issues/17419) - Rebuild MPTT for module bays on upgrade to v4.1 +* [#17492](https://github.com/netbox-community/netbox/issues/17492) - Fix URL resolution in `NetBoxModelSerializer` for plugin models +* [#17497](https://github.com/netbox-community/netbox/issues/17497) - Fix uncaught FieldError exception when referencing an invalid field on a related object during bulk import +* [#17498](https://github.com/netbox-community/netbox/issues/17498) - Fix MultipleObjectsReturned exception when importing a device type without uniquely specifying a manufacturer +* [#17501](https://github.com/netbox-community/netbox/issues/17501) - Fix reporting of last run time & status for custom scripts under UI +* [#17511](https://github.com/netbox-community/netbox/issues/17511) - Restore consistent font support for non-Latin characters +* [#17517](https://github.com/netbox-community/netbox/issues/17517) - Fix cable termination selection after switching termination type +* [#17521](https://github.com/netbox-community/netbox/issues/17521) - Correct text color in notification pop-ups under dark mode +* [#17522](https://github.com/netbox-community/netbox/issues/17522) - Fix language translation of form field labels under user preferences +* [#17537](https://github.com/netbox-community/netbox/issues/17537) - Fix global search support for ASN range names +* [#17555](https://github.com/netbox-community/netbox/issues/17555) - Fix toggling disconnected interfaces under device view +* [#17601](https://github.com/netbox-community/netbox/issues/17601) - Record change to terminating object when disconnecting a cable +* [#17605](https://github.com/netbox-community/netbox/issues/17605) - Fix calculation of aggregate VM disk space under cluster view +* [#17611](https://github.com/netbox-community/netbox/issues/17611) - Correct custom field minimum value validation error message + +--- + ## v4.1.1 (2024-09-12) ### Enhancements diff --git a/mkdocs.yml b/mkdocs.yml index 656a2e03f..00e03a4ce 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -156,7 +156,8 @@ nav: - Administration: - Authentication: - Overview: 'administration/authentication/overview.md' - - Microsoft Azure AD: 'administration/authentication/microsoft-azure-ad.md' + - Google: 'administration/authentication/google.md' + - Microsoft Entra ID: 'administration/authentication/microsoft-entra-id.md' - Okta: 'administration/authentication/okta.md' - Permissions: 'administration/permissions.md' - Error Reporting: 'administration/error-reporting.md' diff --git a/netbox/circuits/api/nested_serializers.py b/netbox/circuits/api/nested_serializers.py index 6de2cbf54..487749872 100644 --- a/netbox/circuits/api/nested_serializers.py +++ b/netbox/circuits/api/nested_serializers.py @@ -18,7 +18,7 @@ __all__ = [ # TODO: Remove in v4.2 warnings.warn( - f"Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.", + "Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.", DeprecationWarning ) diff --git a/netbox/circuits/apps.py b/netbox/circuits/apps.py index df6804303..4d5f177e2 100644 --- a/netbox/circuits/apps.py +++ b/netbox/circuits/apps.py @@ -7,7 +7,7 @@ class CircuitsConfig(AppConfig): def ready(self): from netbox.models.features import register_models - from . import signals, search + from . import signals, search # noqa: F401 # Register models register_models(*self.get_models()) diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index 1e7b6361a..dc334ae88 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -1,5 +1,4 @@ from django import forms -from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from circuits.choices import * diff --git a/netbox/circuits/graphql/filters.py b/netbox/circuits/graphql/filters.py index 3ded6e681..b8398b2b9 100644 --- a/netbox/circuits/graphql/filters.py +++ b/netbox/circuits/graphql/filters.py @@ -1,7 +1,6 @@ -import strawberry import strawberry_django -from circuits import filtersets, models +from circuits import filtersets, models from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin __all__ = ( diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 87e6d99b7..b06ade30b 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -171,7 +171,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) cls.csv_update_data = ( - f"id,cid,description,status", + "id,cid,description,status", f"{circuits[0].pk},Circuit 7,New description7,{CircuitStatusChoices.STATUS_DECOMMISSIONED}", f"{circuits[1].pk},Circuit 8,New description8,{CircuitStatusChoices.STATUS_DECOMMISSIONED}", f"{circuits[2].pk},Circuit 9,New description9,{CircuitStatusChoices.STATUS_DECOMMISSIONED}", diff --git a/netbox/core/api/nested_serializers.py b/netbox/core/api/nested_serializers.py index 3b40853cf..df7b41ca7 100644 --- a/netbox/core/api/nested_serializers.py +++ b/netbox/core/api/nested_serializers.py @@ -16,7 +16,7 @@ __all__ = ( # TODO: Remove in v4.2 warnings.warn( - f"Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.", + "Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.", DeprecationWarning ) diff --git a/netbox/core/api/schema.py b/netbox/core/api/schema.py index 7c4ae722e..1ac822b8c 100644 --- a/netbox/core/api/schema.py +++ b/netbox/core/api/schema.py @@ -8,10 +8,8 @@ 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 rest_framework import serializers -from rest_framework.relations import ManyRelatedField -from netbox.api.fields import ChoiceField, SerializedPKRelatedField +from netbox.api.fields import ChoiceField from netbox.api.serializers import WritableNestedSerializer # see netbox.api.routers.NetBoxRouter diff --git a/netbox/core/api/serializers_/jobs.py b/netbox/core/api/serializers_/jobs.py index e5e07aa41..544dddb56 100644 --- a/netbox/core/api/serializers_/jobs.py +++ b/netbox/core/api/serializers_/jobs.py @@ -1,5 +1,3 @@ -from rest_framework import serializers - from core.choices import * from core.models import Job from netbox.api.fields import ChoiceField, ContentTypeField diff --git a/netbox/core/apps.py b/netbox/core/apps.py index 855ac3170..1dfc7a65e 100644 --- a/netbox/core/apps.py +++ b/netbox/core/apps.py @@ -16,9 +16,9 @@ class CoreConfig(AppConfig): name = "core" def ready(self): - from core.api import schema # noqa + from core.api import schema # noqa: F401 from netbox.models.features import register_models - from . import data_backends, events, search + from . import data_backends, events, search # noqa: F401 # Register models register_models(*self.get_models()) diff --git a/netbox/core/data_backends.py b/netbox/core/data_backends.py index 1b64f5f5c..770a3b258 100644 --- a/netbox/core/data_backends.py +++ b/netbox/core/data_backends.py @@ -8,10 +8,13 @@ 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.socks import ProxyPoolManager from .exceptions import SyncError __all__ = ( @@ -31,7 +34,7 @@ class LocalBackend(DataBackend): @contextmanager def fetch(self): - logger.debug(f"Data source type is local; skipping fetch") + logger.debug("Data source type is local; skipping fetch") local_path = urlparse(self.url).path # Strip file:// scheme yield local_path @@ -67,11 +70,18 @@ class GitBackend(DataBackend): # Initialize backend config config = ConfigDict() + self.use_socks = False # Apply HTTP proxy (if configured) - if settings.HTTP_PROXIES and self.url_scheme in ('http', 'https'): - if proxy := settings.HTTP_PROXIES.get(self.url_scheme): - config.set("http", "proxy", proxy) + 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}") + + 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 return config @@ -87,6 +97,10 @@ class GitBackend(DataBackend): "errstream": porcelain.NoneStream(), } + # 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.url_scheme in ('http', 'https'): if self.params.get('username'): clone_args.update( diff --git a/netbox/core/graphql/mixins.py b/netbox/core/graphql/mixins.py index 43f8761d1..5195b52a0 100644 --- a/netbox/core/graphql/mixins.py +++ b/netbox/core/graphql/mixins.py @@ -15,7 +15,7 @@ __all__ = ( class ChangelogMixin: @strawberry_django.field - def changelog(self, info) -> List[Annotated["ObjectChangeType", strawberry.lazy('.types')]]: + def changelog(self, info) -> List[Annotated["ObjectChangeType", strawberry.lazy('.types')]]: # noqa: F821 content_type = ContentType.objects.get_for_model(self) object_changes = ObjectChange.objects.filter( changed_object_type=content_type, diff --git a/netbox/core/management/commands/syncdatasource.py b/netbox/core/management/commands/syncdatasource.py index aa8137952..990b6eb2a 100644 --- a/netbox/core/management/commands/syncdatasource.py +++ b/netbox/core/management/commands/syncdatasource.py @@ -26,7 +26,7 @@ class Command(BaseCommand): if invalid_names := set(options['name']) - found_names: raise CommandError(f"Invalid data source names: {', '.join(invalid_names)}") else: - raise CommandError(f"Must specify at least one data source, or set --all.") + raise CommandError("Must specify at least one data source, or set --all.") if len(options['name']) > 1: self.stdout.write(f"Syncing {len(datasources)} data sources.") @@ -43,4 +43,4 @@ class Command(BaseCommand): raise e if len(options['name']) > 1: - self.stdout.write(f"Finished.") + self.stdout.write("Finished.") diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py index 97f9fdac7..6824b4605 100644 --- a/netbox/core/models/data.py +++ b/netbox/core/models/data.py @@ -125,7 +125,7 @@ class DataSource(JobsMixin, PrimaryModel): # Ensure URL scheme matches selected type if self.backend_class.is_local and self.url_scheme not in ('file', ''): raise ValidationError({ - 'source_url': f"URLs for local sources must start with file:// (or specify no scheme)" + 'source_url': "URLs for local sources must start with file:// (or specify no scheme)" }) def to_objectchange(self, action): @@ -201,7 +201,7 @@ class DataSource(JobsMixin, PrimaryModel): logger.debug(f"Updated {updated_count} files") # Bulk delete deleted files - deleted_count, _ = DataFile.objects.filter(pk__in=deleted_file_ids).delete() + deleted_count, __ = DataFile.objects.filter(pk__in=deleted_file_ids).delete() logger.debug(f"Deleted {deleted_count} files") # Walk the local replication to find new files diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index 4a327a1d8..3cfea3e2a 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -13,8 +13,6 @@ from django.utils.translation import gettext as _ from core.choices import JobStatusChoices from core.models import ObjectType from core.signals import job_end, job_start -from netbox.config import get_config -from netbox.constants import RQ_QUEUE_DEFAULT from utilities.querysets import RestrictedQuerySet from utilities.rqworker import get_queue_for_model @@ -118,10 +116,11 @@ class Job(models.Model): def get_absolute_url(self): # TODO: Employ dynamic registration - if self.object_type.model == 'reportmodule': - return reverse(f'extras:report_result', kwargs={'job_pk': self.pk}) - if self.object_type.model == 'scriptmodule': - return reverse(f'extras:script_result', kwargs={'job_pk': self.pk}) + if self.object_type: + if self.object_type.model == 'reportmodule': + return reverse('extras:report_result', kwargs={'job_pk': self.pk}) + elif self.object_type.model == 'scriptmodule': + return reverse('extras:script_result', kwargs={'job_pk': self.pk}) return reverse('core:job', args=[self.pk]) def get_status_color(self): @@ -131,7 +130,7 @@ class Job(models.Model): super().clean() # Validate the assigned object type - if self.object_type not in ObjectType.objects.with_feature('jobs'): + if self.object_type and self.object_type not in ObjectType.objects.with_feature('jobs'): raise ValidationError( _("Jobs cannot be assigned to this object type ({type}).").format(type=self.object_type) ) @@ -154,7 +153,7 @@ class Job(models.Model): def delete(self, *args, **kwargs): super().delete(*args, **kwargs) - rq_queue_name = get_config().QUEUE_MAPPINGS.get(self.object_type.model, RQ_QUEUE_DEFAULT) + rq_queue_name = get_queue_for_model(self.object_type.model if self.object_type else None) queue = django_rq.get_queue(rq_queue_name) job = queue.fetch_job(str(self.job_id)) @@ -224,7 +223,7 @@ class Job(models.Model): rq_queue_name = get_queue_for_model(object_type.model if object_type else None) queue = django_rq.get_queue(rq_queue_name) status = JobStatusChoices.STATUS_SCHEDULED if schedule_at else JobStatusChoices.STATUS_PENDING - job = Job.objects.create( + job = Job( object_type=object_type, object_id=object_id, name=name, @@ -234,6 +233,8 @@ class Job(models.Model): user=user, job_id=uuid.uuid4() ) + job.full_clean() + job.save() # Run the job immediately, rather than enqueuing it as a background task. Note that this is a synchronous # (blocking) operation, and execution will pause until the job completes. diff --git a/netbox/core/tests/test_views.py b/netbox/core/tests/test_views.py index 3c847e4ef..01912b4d6 100644 --- a/netbox/core/tests/test_views.py +++ b/netbox/core/tests/test_views.py @@ -308,6 +308,7 @@ class BackgroundTaskTestCase(TestCase): worker = get_worker('default') job = queue.enqueue(self.dummy_job_default) worker.prepare_job_execution(job) + worker.prepare_execution(job) self.assertEqual(job.get_status(), JobStatus.STARTED) diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 5d83b9145..4b8f0db4a 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -56,7 +56,7 @@ __all__ = [ # TODO: Remove in v4.2 warnings.warn( - f"Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.", + "Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.", DeprecationWarning ) diff --git a/netbox/dcim/api/serializers_/manufacturers.py b/netbox/dcim/api/serializers_/manufacturers.py index 61158e0f7..1a1eea6ec 100644 --- a/netbox/dcim/api/serializers_/manufacturers.py +++ b/netbox/dcim/api/serializers_/manufacturers.py @@ -1,5 +1,3 @@ -from rest_framework import serializers - from dcim.models import Manufacturer from netbox.api.fields import RelatedObjectCountField from netbox.api.serializers import NetBoxModelSerializer diff --git a/netbox/dcim/api/serializers_/platforms.py b/netbox/dcim/api/serializers_/platforms.py index 3c846f8fd..2f4745701 100644 --- a/netbox/dcim/api/serializers_/platforms.py +++ b/netbox/dcim/api/serializers_/platforms.py @@ -1,5 +1,3 @@ -from rest_framework import serializers - from dcim.models import Platform from extras.api.serializers_.configtemplates import ConfigTemplateSerializer from netbox.api.fields import RelatedObjectCountField diff --git a/netbox/dcim/api/serializers_/power.py b/netbox/dcim/api/serializers_/power.py index fc65a0732..4c2cf54fb 100644 --- a/netbox/dcim/api/serializers_/power.py +++ b/netbox/dcim/api/serializers_/power.py @@ -1,5 +1,3 @@ -from rest_framework import serializers - from dcim.choices import * from dcim.models import PowerFeed, PowerPanel from netbox.api.fields import ChoiceField, RelatedObjectCountField diff --git a/netbox/dcim/api/serializers_/roles.py b/netbox/dcim/api/serializers_/roles.py index e9c9d3563..8f922da10 100644 --- a/netbox/dcim/api/serializers_/roles.py +++ b/netbox/dcim/api/serializers_/roles.py @@ -1,5 +1,3 @@ -from rest_framework import serializers - from dcim.models import DeviceRole, InventoryItemRole from extras.api.serializers_.configtemplates import ConfigTemplateSerializer from netbox.api.fields import RelatedObjectCountField diff --git a/netbox/dcim/apps.py b/netbox/dcim/apps.py index 4df66e367..9653d3b93 100644 --- a/netbox/dcim/apps.py +++ b/netbox/dcim/apps.py @@ -10,7 +10,7 @@ class DCIMConfig(AppConfig): def ready(self): from netbox.models.features import register_models from utilities.counters import connect_counters - from . import signals, search + from . import signals, search # noqa: F401 from .models import CableTermination, Device, DeviceType, VirtualChassis # Register models diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index c90a0b3ea..11d192732 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -197,6 +197,9 @@ class DeviceAirflowChoices(ChoiceSet): AIRFLOW_LEFT_TO_RIGHT = 'left-to-right' AIRFLOW_RIGHT_TO_LEFT = 'right-to-left' AIRFLOW_SIDE_TO_REAR = 'side-to-rear' + AIRFLOW_REAR_TO_SIDE = 'rear-to-side' + AIRFLOW_BOTTOM_TO_TOP = 'bottom-to-top' + AIRFLOW_TOP_TO_BOTTOM = 'top-to-bottom' AIRFLOW_PASSIVE = 'passive' AIRFLOW_MIXED = 'mixed' @@ -206,6 +209,9 @@ class DeviceAirflowChoices(ChoiceSet): (AIRFLOW_LEFT_TO_RIGHT, _('Left to right')), (AIRFLOW_RIGHT_TO_LEFT, _('Right to left')), (AIRFLOW_SIDE_TO_REAR, _('Side to rear')), + (AIRFLOW_REAR_TO_SIDE, _('Rear to side')), + (AIRFLOW_BOTTOM_TO_TOP, _('Bottom to top')), + (AIRFLOW_TOP_TO_BOTTOM, _('Top to bottom')), (AIRFLOW_PASSIVE, _('Passive')), (AIRFLOW_MIXED, _('Mixed')), ) @@ -863,6 +869,7 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_100ME_LFX = '100base-lfx' TYPE_100ME_FIXED = '100base-tx' TYPE_100ME_T1 = '100base-t1' + TYPE_100ME_SFP = '100base-x-sfp' TYPE_1GE_FIXED = '1000base-t' TYPE_1GE_TX_FIXED = '1000base-tx' TYPE_1GE_GBIC = '1000base-x-gbic' @@ -924,6 +931,7 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_80211AY = 'ieee802.11ay' TYPE_80211BE = 'ieee802.11be' TYPE_802151 = 'ieee802.15.1' + TYPE_802154 = 'ieee802.15.4' TYPE_OTHER_WIRELESS = 'other-wireless' # Cellular @@ -1035,6 +1043,7 @@ class InterfaceTypeChoices(ChoiceSet): ( _('Ethernet (modular)'), ( + (TYPE_100ME_SFP, 'SFP (100ME)'), (TYPE_1GE_GBIC, 'GBIC (1GE)'), (TYPE_1GE_SFP, 'SFP (1GE)'), (TYPE_10GE_SFP_PLUS, 'SFP+ (10GE)'), @@ -1096,6 +1105,7 @@ class InterfaceTypeChoices(ChoiceSet): (TYPE_80211AY, 'IEEE 802.11ay'), (TYPE_80211BE, 'IEEE 802.11be'), (TYPE_802151, 'IEEE 802.15.1 (Bluetooth)'), + (TYPE_802154, 'IEEE 802.15.4 (LR-WPAN)'), (TYPE_OTHER_WIRELESS, 'Other (Wireless)'), ) ), diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 049be6117..ba3e6464b 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -51,6 +51,7 @@ WIRELESS_IFACE_TYPES = [ InterfaceTypeChoices.TYPE_80211AY, InterfaceTypeChoices.TYPE_80211BE, InterfaceTypeChoices.TYPE_802151, + InterfaceTypeChoices.TYPE_802154, InterfaceTypeChoices.TYPE_OTHER_WIRELESS, ] diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 6517aadb4..5a101e739 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -271,7 +271,7 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM class Meta: model = Location - fields = ('id', 'name', 'slug', 'status', 'facility', 'description') + fields = ('id', 'name', 'slug', 'facility', 'description') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 9b4a8475b..58ae35091 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -9,7 +9,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from extras.models import ConfigTemplate -from ipam.models import VRF +from ipam.models import VRF, IPAddress from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant from utilities.forms.fields import ( @@ -367,13 +367,13 @@ class ManufacturerImportForm(NetBoxModelImportForm): class DeviceTypeImportForm(NetBoxModelImportForm): - manufacturer = forms.ModelChoiceField( + manufacturer = CSVModelChoiceField( label=_('Manufacturer'), queryset=Manufacturer.objects.all(), to_field_name='name', help_text=_('The manufacturer which produces this device type') ) - default_platform = forms.ModelChoiceField( + default_platform = CSVModelChoiceField( label=_('Default platform'), queryset=Platform.objects.all(), to_field_name='name', @@ -1435,9 +1435,33 @@ class VirtualDeviceContextImportForm(NetBoxModelImportForm): label=_('Status'), choices=VirtualDeviceContextStatusChoices, ) + primary_ip4 = CSVModelChoiceField( + label=_('Primary IPv4'), + queryset=IPAddress.objects.all(), + required=False, + to_field_name='address', + help_text=_('IPv4 address with mask, e.g. 1.2.3.4/24') + ) + primary_ip6 = CSVModelChoiceField( + label=_('Primary IPv6'), + queryset=IPAddress.objects.all(), + required=False, + to_field_name='address', + help_text=_('IPv6 address with prefix length, e.g. 2001:db8::1/64') + ) class Meta: fields = [ - 'name', 'device', 'status', 'tenant', 'identifier', 'comments', + 'name', 'device', 'status', 'tenant', 'identifier', 'comments', 'primary_ip4', 'primary_ip6', ] model = VirtualDeviceContext + + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + + if data: + + # Limit primary_ip4/ip6 querysets by assigned device + params = {f"interface__device__{self.fields['device'].to_field_name}": data.get('device')} + self.fields['primary_ip4'].queryset = self.fields['primary_ip4'].queryset.filter(**params) + self.fields['primary_ip6'].queryset = self.fields['primary_ip6'].queryset.filter(**params) diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py index f107c3476..324f8ecfd 100644 --- a/netbox/dcim/forms/connections.py +++ b/netbox/dcim/forms/connections.py @@ -4,7 +4,7 @@ from django.utils.translation import gettext_lazy as _ from circuits.models import Circuit, CircuitTermination from dcim.models import * -from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField +from utilities.forms.fields import DynamicModelMultipleChoiceField from .model_forms import CableForm diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 6e0a47979..908312a8a 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -954,7 +954,7 @@ class PowerOutletTemplateForm(ModularComponentTemplateForm): queryset=PowerPortTemplate.objects.all(), required=False, query_params={ - 'devicetype_id': '$device_type', + 'device_type_id': '$device_type', } ) @@ -1001,8 +1001,8 @@ class FrontPortTemplateForm(ModularComponentTemplateForm): queryset=RearPortTemplate.objects.all(), required=False, query_params={ - 'devicetype_id': '$device_type', - 'moduletype_id': '$module_type', + 'device_type_id': '$device_type', + 'module_type_id': '$module_type', } ) @@ -1063,7 +1063,7 @@ class InventoryItemTemplateForm(ComponentTemplateForm): queryset=InventoryItemTemplate.objects.all(), required=False, query_params={ - 'devicetype_id': '$device_type' + 'device_type_id': '$device_type' } ) role = DynamicModelChoiceField( @@ -1351,7 +1351,8 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), required=False, - label=_('VLAN group') + label=_('VLAN group'), + help_text=_("Filter VLANs available for assignment by group.") ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index f811700b4..d18c7ed14 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -261,8 +261,8 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm): # TODO: Clean up the application of HTMXSelect attributes attrs={ 'hx-get': '.', - 'hx-include': f'#form_fields', - 'hx-target': f'#form_fields', + 'hx-include': '#form_fields', + 'hx-target': '#form_fields', } ) ) diff --git a/netbox/dcim/graphql/mixins.py b/netbox/dcim/graphql/mixins.py index 589af50c8..2e5ab7ea7 100644 --- a/netbox/dcim/graphql/mixins.py +++ b/netbox/dcim/graphql/mixins.py @@ -1,7 +1,6 @@ from typing import Annotated, List, Union import strawberry -import strawberry_django __all__ = ( 'CabledObjectMixin', @@ -11,18 +10,18 @@ __all__ = ( @strawberry.type class CabledObjectMixin: - cable: Annotated["CableType", strawberry.lazy('dcim.graphql.types')] | None + cable: Annotated["CableType", strawberry.lazy('dcim.graphql.types')] | None # noqa: F821 link_peers: List[Annotated[Union[ - Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')], - Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], - Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], - Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], - Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], - Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')], - Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')], - Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')], - Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["CircuitTerminationType", 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 + Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], # noqa: F821 + Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')], # noqa: F821 + Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')], # noqa: F821 + Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821 + Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821 ], strawberry.union("LinkPeerType")]] @@ -30,14 +29,14 @@ class CabledObjectMixin: class PathEndpointMixin: connected_endpoints: List[Annotated[Union[ - Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')], - Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], - Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], - Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], - Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], - Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')], - Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')], - Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')], - Annotated["ProviderNetworkType", strawberry.lazy('circuits.graphql.types')], - Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["CircuitTerminationType", 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 + Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], # noqa: F821 + Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')], # noqa: F821 + Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')], # noqa: F821 + Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821 + Annotated["ProviderNetworkType", strawberry.lazy('circuits.graphql.types')], # noqa: F821 + Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821 ], strawberry.union("ConnectedEndpointType")]] diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 24ba5cca4..bce6f06ac 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -112,7 +112,7 @@ class ModularComponentTemplateType(ComponentTemplateType): @strawberry_django.type( models.CableTermination, - exclude=('termination_type', 'termination_id'), + exclude=('termination_type', 'termination_id', '_device', '_rack', '_location', '_site'), filters=CableTerminationFilter ) class CableTerminationType(NetBoxObjectType): @@ -243,6 +243,7 @@ class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, NetBo consoleserverports: List[Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')]] poweroutlets: List[Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')]] frontports: List[Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')]] + devicebays: List[Annotated["DeviceBayType", strawberry.lazy('dcim.graphql.types')]] modulebays: List[Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')]] services: List[Annotated["ServiceType", strawberry.lazy('ipam.graphql.types')]] inventoryitems: List[Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')]] diff --git a/netbox/dcim/management/commands/trace_paths.py b/netbox/dcim/management/commands/trace_paths.py index d34a428e4..592aeb6a7 100644 --- a/netbox/dcim/management/commands/trace_paths.py +++ b/netbox/dcim/management/commands/trace_paths.py @@ -60,7 +60,7 @@ class Command(BaseCommand): self.stdout.write((self.style.SUCCESS(f' Deleted {deleted_count} paths'))) # Reinitialize the model's PK sequence - self.stdout.write(f'Resetting database sequence for CablePath model') + self.stdout.write('Resetting database sequence for CablePath model') sequence_sql = connection.ops.sequence_reset_sql(no_style(), [CablePath]) with connection.cursor() as cursor: for sql in sequence_sql: diff --git a/netbox/dcim/migrations/0191_module_bay_rebuild.py b/netbox/dcim/migrations/0191_module_bay_rebuild.py new file mode 100644 index 000000000..260063213 --- /dev/null +++ b/netbox/dcim/migrations/0191_module_bay_rebuild.py @@ -0,0 +1,26 @@ +from django.db import migrations +import mptt +import mptt.managers + + +def rebuild_mptt(apps, schema_editor): + manager = mptt.managers.TreeManager() + ModuleBay = apps.get_model('dcim', 'ModuleBay') + manager.model = ModuleBay + mptt.register(ModuleBay) + manager.contribute_to_class(ModuleBay, 'objects') + manager.rebuild() + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0190_nested_modules'), + ] + + operations = [ + migrations.RunPython( + code=rebuild_mptt, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 2fac55dd4..2a4748610 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -164,7 +164,7 @@ class Cable(PrimaryModel): if self.length is not None and not self.length_unit: raise ValidationError(_("Must specify a unit when setting a cable length")) - if self.pk is None and (not self.a_terminations or not self.b_terminations): + if self._state.adding and self.pk is None and (not self.a_terminations or not self.b_terminations): raise ValidationError(_("Must define A and B terminations when creating a new cable.")) if self._terminations_modified: @@ -366,11 +366,11 @@ class CableTermination(ChangeLoggedModel): def delete(self, *args, **kwargs): # Delete the cable association on the terminating object - termination_model = self.termination._meta.model - termination_model.objects.filter(pk=self.termination_id).update( - cable=None, - cable_end='' - ) + termination = self.termination._meta.model.objects.get(pk=self.termination_id) + termination.snapshot() + termination.cable = None + termination.cable_end = '' + termination.save() super().delete(*args, **kwargs) @@ -666,6 +666,14 @@ class CablePath(models.Model): rear_port_id=remote_terminations[0].pk, rear_port_position__in=position_stack.pop() ) + # If all rear ports have a single position, we can just get the front ports + elif all([rp.positions == 1 for rp in remote_terminations]): + front_ports = FrontPort.objects.filter(rear_port_id__in=[rp.pk for rp in remote_terminations]) + + if len(front_ports) != len(remote_terminations): + # Some rear ports does not have a front port + is_split = True + break else: # No position indicated: path has split, so we stop at the RearPorts is_split = True diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 28a403be0..3a71c424d 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -98,7 +98,7 @@ class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin): def clean(self): super().clean() - if self.pk is not None and self._original_device_type != self.device_type_id: + if not self._state.adding and self._original_device_type != self.device_type_id: raise ValidationError({ "device_type": _("Component templates cannot be moved to a different device type.") }) @@ -160,7 +160,6 @@ class ModularComponentTemplateModel(ComponentTemplateModel): def _get_module_tree(self, module): modules = [] - all_module_bays = module.device.modulebays.all().select_related('module') while module: modules.append(module) if module.module_bay: diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 62312cbf4..b1f951541 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -4,7 +4,7 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelatio from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -from django.db.models import F, Sum +from django.db.models import Sum from django.urls import reverse from django.utils.translation import gettext_lazy as _ from mptt.models import MPTTModel, TreeForeignKey @@ -22,7 +22,6 @@ from utilities.tracking import TrackingModelMixin from wireless.choices import * from wireless.utils import get_channel_attr - __all__ = ( 'BaseInterface', 'CabledObjectModel', @@ -561,7 +560,7 @@ class BaseInterface(models.Model): self.untagged_vlan = None # Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.) - if self.pk and self.mode != InterfaceModeChoices.MODE_TAGGED: + if not self._state.adding and self.mode != InterfaceModeChoices.MODE_TAGGED: self.tagged_vlans.clear() return super().save(*args, **kwargs) @@ -1072,7 +1071,7 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin): super().clean() # Check that positions count is greater than or equal to the number of associated FrontPorts - if self.pk: + if not self._state.adding: frontport_count = self.frontports.count() if self.positions < frontport_count: raise ValidationError({ @@ -1314,7 +1313,7 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin): }) # Validation for moving InventoryItems - if self.pk: + if not self._state.adding: # Cannot move an InventoryItem to another device if it has a parent if self.parent and self.parent.device != self.device: raise ValidationError({ diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index c281e5de2..0f11cb5f3 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -293,7 +293,7 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): # If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have # room to expand within their racks. This validation will impose a very high performance penalty when there are # many instances to check, but increasing the u_height of a DeviceType should be a very rare occurrence. - if self.pk and self.u_height > self._original_u_height: + if not self._state.adding and self.u_height > self._original_u_height: for d in Device.objects.filter(device_type=self, position__isnull=False): face_required = None if self.is_full_depth else d.face u_available = d.rack.get_available_units( @@ -310,7 +310,7 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): }) # If modifying the height of an existing DeviceType to 0U, check for any instances assigned to a rack position. - elif self.pk and self._original_u_height > 0 and self.u_height == 0: + elif not self._state.adding and self._original_u_height > 0 and self.u_height == 0: racked_instance_count = Device.objects.filter( device_type=self, position__isnull=False @@ -983,6 +983,13 @@ class Device( 'vc_position': _("A device assigned to a virtual chassis must have its position defined.") }) + if hasattr(self, 'vc_master_for') and self.vc_master_for and self.vc_master_for != self.virtual_chassis: + raise ValidationError({ + 'virtual_chassis': _('Device cannot be removed from virtual chassis {virtual_chassis} because it is currently designated as its master.').format( + virtual_chassis=self.vc_master_for + ) + }) + def _instantiate_components(self, queryset, bulk_create=True): """ Instantiate components for the device from the specified component templates. @@ -1351,7 +1358,7 @@ class VirtualChassis(PrimaryModel): # Verify that the selected master device has been assigned to this VirtualChassis. (Skip when creating a new # VirtualChassis.) - if self.pk and self.master and self.master not in self.members.all(): + if not self._state.adding and self.master and self.master not in self.members.all(): raise ValidationError({ 'master': _("The selected master ({master}) is not assigned to this virtual chassis.").format( master=self.master diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 3198c2679..3aead09ca 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -382,7 +382,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): if self.max_weight and not self.weight_unit: raise ValidationError(_("Must specify a unit when setting a maximum weight")) - if self.pk: + if not self._state.adding: mounted_devices = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('position') # Validate that Rack is tall enough to house the highest mounted Device @@ -468,7 +468,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): } # Add devices to rack units list - if self.pk: + if not self._state.adding: # Retrieve all devices installed within the rack devices = Device.objects.prefetch_related( diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py index 38c1843fe..45431cb05 100644 --- a/netbox/dcim/search.py +++ b/netbox/dcim/search.py @@ -250,7 +250,7 @@ class RackTypeIndex(SearchIndex): ('description', 500), ('comments', 5000), ) - display_attrs = ('type', 'description') + display_attrs = ('model', 'description') @register_search diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py index 959414d75..4e0f7aea6 100644 --- a/netbox/dcim/svg/cables.py +++ b/netbox/dcim/svg/cables.py @@ -162,6 +162,9 @@ class CableTraceSVG: location_label += f' / {instance.location}' if instance.rack: location_label += f' / {instance.rack}' + if instance.position: + location_label += f' / {instance.get_face_display()}' + location_label += f' / U{instance.position}' labels.append(location_label) elif instance._meta.model_name == 'circuit': labels[0] = f'Circuit {instance}' diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 222d756a1..70b297366 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -588,6 +588,9 @@ class BaseInterfaceTable(NetBoxTable): def value_ip_addresses(self, value): return ",".join([str(obj.address) for obj in value.all()]) + def value_tagged_vlans(self, value): + return ",".join([str(obj) for obj in value.all()]) + class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpointTable): device = tables.Column( @@ -684,7 +687,8 @@ class DeviceInterfaceTable(InterfaceTable): 'data-virtual': lambda record: "true" if record.is_virtual else "false", 'data-mark-connected': lambda record: "true" if record.mark_connected else "false", 'data-cable-status': lambda record: record.cable.status if record.cable else "", - 'data-type': lambda record: record.type + 'data-type': lambda record: record.type, + 'data-connected': lambda record: "connected" if record.mark_connected or record.cable else "disconnected" } diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 69ff8b3a2..e8a4e35f1 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -1,6 +1,5 @@ -from django.utils.translation import gettext_lazy as _ import django_tables2 as tables -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from dcim import models from netbox.tables import NetBoxTable, columns diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index d3134656c..96ab803e6 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -56,9 +56,13 @@ INTERFACE_FHRPGROUPS = """ INTERFACE_TAGGED_VLANS = """ {% if record.mode == 'tagged' %} + {% if value.count > 3 %} + {{ value.count }} VLANs + {% else %} {% for vlan in value.all %} {{ vlan }}
{% endfor %} + {% endif %} {% elif record.mode == 'tagged-all' %} All {% endif %} diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 573fdbb96..1b460cd59 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -2135,12 +2135,12 @@ class ConnectedDeviceTest(APITestCase): def test_get_connected_device(self): url = reverse('dcim-api:connected-device-list') - url_params = f'?peer_device=TestDevice1&peer_interface=eth0' + url_params = '?peer_device=TestDevice1&peer_interface=eth0' response = self.client.get(url + url_params, **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(response.data['name'], 'TestDevice2') - url_params = f'?peer_device=TestDevice1&peer_interface=eth1' + url_params = '?peer_device=TestDevice1&peer_interface=eth1' response = self.client.get(url + url_params, **self.header) self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index cd7b0e6d7..f7c337bdf 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -2060,6 +2060,49 @@ class CablePathTestCase(TestCase): # Test SVG generation CableTraceSVG(interface1).render() + def test_222_single_path_via_multiple_singleposition_rear_ports(self): + """ + [IF1] --C1-- [FP1] [RP1] --C2-- [IF2] + [FP2] [RP2] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + ) + frontport2 = FrontPort.objects.create( + device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 + ) + + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1, frontport2] + ) + cable1.save() + self.assertEqual(CablePath.objects.count(), 1) + + cable2 = Cable( + a_terminations=[rearport1, rearport2], + b_terminations=[interface2] + ) + cable2.save() + self.assertEqual(CablePath.objects.count(), 2) + + self.assertPathExists( + (interface1, cable1, (frontport1, frontport2), (rearport1, rearport2), cable2, interface2), + is_complete=True + ) + self.assertPathExists( + (interface2, cable2, (rearport1, rearport2), (frontport1, frontport2), cable1, interface1), + is_complete=True + ) + + # Test SVG generation both directions + CableTraceSVG(interface1).render() + CableTraceSVG(interface2).render() + def test_301_create_path_via_existing_cable(self): """ [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index e2d52a609..6c65cad93 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -4838,13 +4838,6 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'device_role': [role[0].slug, role[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - def test_role(self): - role = DeviceRole.objects.all()[:2] - params = {'role_id': [role[0].pk, role[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'role': [role[0].slug, role[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - def test_device(self): devices = Device.objects.all()[:2] params = {'device_id': [devices[0].pk, devices[1].pk]} @@ -5247,6 +5240,10 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): def test_type(self): params = {'type': [CableTypeChoices.TYPE_CAT3, CableTypeChoices.TYPE_CAT5E]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'type__empty': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + params = {'type__empty': 'false'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) def test_status(self): params = {'status': [LinkStatusChoices.STATUS_CONNECTED]} diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 1c3dbb90b..70431c2e1 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -662,10 +662,8 @@ class ModuleBayTestCase(TestCase): def test_module_bay_recursion(self): module_bay_1 = ModuleBay.objects.get(name='Module Bay 1') - module_bay_2 = ModuleBay.objects.get(name='Module Bay 2') module_bay_3 = ModuleBay.objects.get(name='Module Bay 3') module_1 = Module.objects.get(module_bay=module_bay_1) - module_2 = Module.objects.get(module_bay=module_bay_2) module_3 = Module.objects.get(module_bay=module_bay_3) # Confirm error if ModuleBay recurses @@ -681,8 +679,6 @@ class ModuleBayTestCase(TestCase): module_1.save() def test_single_module_token(self): - module_bays = ModuleBay.objects.all() - modules = Module.objects.all() device_type = DeviceType.objects.first() device_role = DeviceRole.objects.first() site = Site.objects.first() @@ -708,7 +704,7 @@ class ModuleBayTestCase(TestCase): location=location, rack=rack ) - cp = device.consoleports.first() + device.consoleports.first() def test_nested_module_token(self): pass @@ -733,39 +729,41 @@ class CableTestCase(TestCase): device2 = Device.objects.create( device_type=devicetype, role=role, name='TestDevice2', site=site ) - interface1 = Interface.objects.create(device=device1, name='eth0') - interface2 = Interface.objects.create(device=device2, name='eth0') - interface3 = Interface.objects.create(device=device2, name='eth1') - Cable(a_terminations=[interface1], b_terminations=[interface2]).save() + interfaces = ( + Interface(device=device1, name='eth0'), + Interface(device=device2, name='eth0'), + Interface(device=device2, name='eth1'), + ) + Interface.objects.bulk_create(interfaces) + Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[1]]).save() + PowerPort.objects.create(device=device2, name='psu1') - power_port1 = PowerPort.objects.create(device=device2, name='psu1') - patch_pannel = Device.objects.create( + patch_panel = Device.objects.create( device_type=devicetype, role=role, name='TestPatchPanel', site=site ) - rear_port1 = RearPort.objects.create(device=patch_pannel, name='RP1', type='8p8c') - front_port1 = FrontPort.objects.create( - device=patch_pannel, name='FP1', type='8p8c', rear_port=rear_port1, rear_port_position=1 + rear_ports = ( + RearPort(device=patch_panel, name='RP1', type='8p8c'), + RearPort(device=patch_panel, name='RP2', type='8p8c', positions=2), + RearPort(device=patch_panel, name='RP3', type='8p8c', positions=3), + RearPort(device=patch_panel, name='RP4', type='8p8c', positions=3), ) - rear_port2 = RearPort.objects.create(device=patch_pannel, name='RP2', type='8p8c', positions=2) - front_port2 = FrontPort.objects.create( - device=patch_pannel, name='FP2', type='8p8c', rear_port=rear_port2, rear_port_position=1 - ) - rear_port3 = RearPort.objects.create(device=patch_pannel, name='RP3', type='8p8c', positions=3) - front_port3 = FrontPort.objects.create( - device=patch_pannel, name='FP3', type='8p8c', rear_port=rear_port3, rear_port_position=1 - ) - rear_port4 = RearPort.objects.create(device=patch_pannel, name='RP4', type='8p8c', positions=3) - front_port4 = FrontPort.objects.create( - device=patch_pannel, name='FP4', type='8p8c', rear_port=rear_port4, rear_port_position=1 + RearPort.objects.bulk_create(rear_ports) + front_ports = ( + FrontPort(device=patch_panel, name='FP1', type='8p8c', rear_port=rear_ports[0], rear_port_position=1), + FrontPort(device=patch_panel, name='FP2', type='8p8c', rear_port=rear_ports[1], rear_port_position=1), + FrontPort(device=patch_panel, name='FP3', type='8p8c', rear_port=rear_ports[2], rear_port_position=1), + FrontPort(device=patch_panel, name='FP4', type='8p8c', rear_port=rear_ports[3], rear_port_position=1), ) + FrontPort.objects.bulk_create(front_ports) + provider = Provider.objects.create(name='Provider 1', slug='provider-1') provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=provider) circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') circuit1 = Circuit.objects.create(provider=provider, type=circuittype, cid='1') circuit2 = Circuit.objects.create(provider=provider, type=circuittype, cid='2') - circuittermination1 = CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='A') - circuittermination2 = CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='Z') - circuittermination3 = CircuitTermination.objects.create(circuit=circuit2, provider_network=provider_network, term_side='A') + CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='A') + CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='Z') + CircuitTermination.objects.create(circuit=circuit2, provider_network=provider_network, term_side='A') def test_cable_creation(self): """ diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 7d6c34337..e290a6d1d 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -2571,7 +2571,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): } cls.csv_data = ( - f"device,name,type,vrf.pk,poe_mode,poe_type", + "device,name,type,vrf.pk,poe_mode,poe_type", f"Device 1,Interface 4,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af", f"Device 1,Interface 5,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af", f"Device 1,Interface 6,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af", diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index eadd2da96..4d4228490 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -1,5 +1,3 @@ -import itertools - from django.contrib.contenttypes.models import ContentType from django.db import transaction diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 8036178ed..66308677d 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -11,7 +11,7 @@ from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.html import escape from django.utils.safestring import mark_safe -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from django.views.generic import View from jinja2.exceptions import TemplateError @@ -3261,10 +3261,10 @@ class CableEditView(generic.ObjectEditView): doesn't currently provide a hook for dynamic class resolution. """ a_terminations_type = CABLE_TERMINATION_TYPES.get( - request.GET.get('a_terminations_type') or request.POST.get('a_terminations_type') + request.POST.get('a_terminations_type') or request.GET.get('a_terminations_type') ) b_terminations_type = CABLE_TERMINATION_TYPES.get( - request.GET.get('b_terminations_type') or request.POST.get('b_terminations_type') + request.POST.get('b_terminations_type') or request.GET.get('b_terminations_type') ) if obj.pk: diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py index ba291b34e..235cdd6d6 100644 --- a/netbox/extras/api/nested_serializers.py +++ b/netbox/extras/api/nested_serializers.py @@ -24,7 +24,7 @@ __all__ = [ # TODO: Remove in v4.2 warnings.warn( - f"Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.", + "Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.", DeprecationWarning ) diff --git a/netbox/extras/api/serializers_/configtemplates.py b/netbox/extras/api/serializers_/configtemplates.py index 30d2fb468..c4a683c74 100644 --- a/netbox/extras/api/serializers_/configtemplates.py +++ b/netbox/extras/api/serializers_/configtemplates.py @@ -1,5 +1,3 @@ -from rest_framework import serializers - from core.api.serializers_.data import DataFileSerializer, DataSourceSerializer from extras.models import ConfigTemplate from netbox.api.serializers import ValidatedModelSerializer diff --git a/netbox/extras/api/serializers_/customlinks.py b/netbox/extras/api/serializers_/customlinks.py index 6391e0471..8cc4f5f77 100644 --- a/netbox/extras/api/serializers_/customlinks.py +++ b/netbox/extras/api/serializers_/customlinks.py @@ -1,5 +1,3 @@ -from rest_framework import serializers - from core.models import ObjectType from extras.models import CustomLink from netbox.api.fields import ContentTypeField diff --git a/netbox/extras/api/serializers_/exporttemplates.py b/netbox/extras/api/serializers_/exporttemplates.py index faef9bb9e..11f502a02 100644 --- a/netbox/extras/api/serializers_/exporttemplates.py +++ b/netbox/extras/api/serializers_/exporttemplates.py @@ -1,5 +1,3 @@ -from rest_framework import serializers - from core.api.serializers_.data import DataFileSerializer, DataSourceSerializer from core.models import ObjectType from extras.models import ExportTemplate diff --git a/netbox/extras/api/serializers_/savedfilters.py b/netbox/extras/api/serializers_/savedfilters.py index 140303797..fb0744e59 100644 --- a/netbox/extras/api/serializers_/savedfilters.py +++ b/netbox/extras/api/serializers_/savedfilters.py @@ -1,5 +1,3 @@ -from rest_framework import serializers - from core.models import ObjectType from extras.models import SavedFilter from netbox.api.fields import ContentTypeField diff --git a/netbox/extras/api/serializers_/tags.py b/netbox/extras/api/serializers_/tags.py index 946ed3c8a..e4e62845a 100644 --- a/netbox/extras/api/serializers_/tags.py +++ b/netbox/extras/api/serializers_/tags.py @@ -1,5 +1,3 @@ -from rest_framework import serializers - from core.models import ObjectType from extras.models import Tag from netbox.api.fields import ContentTypeField, RelatedObjectCountField diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index cf2cab316..e4c3c7f3e 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,6 +1,5 @@ from django.http import Http404 from django.shortcuts import get_object_or_404 -from django.utils.module_loading import import_string from django_rq.queues import get_connection from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import status @@ -15,8 +14,8 @@ from rq import Worker from core.models import ObjectType from extras import filtersets -from extras.models import * from extras.jobs import ScriptJob +from extras.models import * from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.features import SyncedDataMixin from netbox.api.metadata import ContentTypeMetadata diff --git a/netbox/extras/apps.py b/netbox/extras/apps.py index c565988bc..21232f95f 100644 --- a/netbox/extras/apps.py +++ b/netbox/extras/apps.py @@ -6,7 +6,7 @@ class ExtrasConfig(AppConfig): def ready(self): from netbox.models.features import register_models - from . import dashboard, lookups, search, signals + from . import dashboard, lookups, search, signals # noqa: F401 # Register models register_models(*self.get_models()) diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index 091819bd1..c56e4cd7d 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/netbox/extras/dashboard/widgets.py @@ -15,7 +15,6 @@ from django.utils.translation import gettext as _ from core.models import ObjectType from extras.choices import BookmarkOrderingChoices -from netbox.choices import ButtonColorChoices from utilities.object_types import object_type_identifier, object_type_name from utilities.permissions import get_permission_for_model from utilities.querydict import dict_to_querydict diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index a43f80cc3..a53c7bed3 100644 --- a/netbox/extras/graphql/types.py +++ b/netbox/extras/graphql/types.py @@ -84,7 +84,7 @@ class CustomFieldType(ObjectType): class CustomFieldChoiceSetType(ObjectType): choices_for: List[Annotated["CustomFieldType", strawberry.lazy('extras.graphql.types')]] - extra_choices: List[str] | None + extra_choices: List[List[str]] | None @strawberry_django.type( diff --git a/netbox/extras/jobs.py b/netbox/extras/jobs.py index e540ef439..a913fe456 100644 --- a/netbox/extras/jobs.py +++ b/netbox/extras/jobs.py @@ -48,8 +48,7 @@ class ScriptJob(JobRunner): except AbortTransaction: script.log_info(message=_("Database changes have been reverted automatically.")) if script.failed: - logger.warning(f"Script failed") - raise + logger.warning("Script failed") except Exception as e: if type(e) is AbortScript: diff --git a/netbox/extras/lookups.py b/netbox/extras/lookups.py index a8d89c943..c496cce78 100644 --- a/netbox/extras/lookups.py +++ b/netbox/extras/lookups.py @@ -1,4 +1,5 @@ -from django.db.models import CharField, TextField, Lookup +from django.db.models import CharField, Lookup + from .fields import CachedValueField diff --git a/netbox/extras/management/commands/housekeeping.py b/netbox/extras/management/commands/housekeeping.py index cb8137ee2..ade486fc0 100644 --- a/netbox/extras/management/commands/housekeeping.py +++ b/netbox/extras/management/commands/housekeeping.py @@ -95,7 +95,7 @@ class Command(BaseCommand): self.stdout.write("[*] Checking for latest release") if settings.ISOLATED_DEPLOYMENT: if options['verbosity']: - self.stdout.write(f"\tSkipping: ISOLATED_DEPLOYMENT is enabled") + self.stdout.write("\tSkipping: ISOLATED_DEPLOYMENT is enabled") elif settings.RELEASE_CHECK_URL: headers = { 'Accept': 'application/vnd.github.v3+json', @@ -129,7 +129,7 @@ class Command(BaseCommand): self.stdout.write(f"\tRequest error: {exc}", self.style.ERROR) else: if options['verbosity']: - self.stdout.write(f"\tSkipping: RELEASE_CHECK_URL not set") + self.stdout.write("\tSkipping: RELEASE_CHECK_URL not set") if options['verbosity']: self.stdout.write("Finished.", self.style.SUCCESS) diff --git a/netbox/extras/management/commands/reindex.py b/netbox/extras/management/commands/reindex.py index 5aab74511..21442be93 100644 --- a/netbox/extras/management/commands/reindex.py +++ b/netbox/extras/management/commands/reindex.py @@ -96,9 +96,9 @@ class Command(BaseCommand): if i: self.stdout.write(f'{i} entries cached.') else: - self.stdout.write(f'No objects found.') + self.stdout.write('No objects found.') - msg = f'Completed.' + msg = 'Completed.' if total_count := search_backend.size: msg += f' Total entries: {total_count}' self.stdout.write(msg, self.style.SUCCESS) diff --git a/netbox/extras/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py index ab0d6d894..d5fb435ad 100644 --- a/netbox/extras/management/commands/runscript.py +++ b/netbox/extras/management/commands/runscript.py @@ -51,7 +51,7 @@ class Command(BaseCommand): user = User.objects.filter(is_superuser=True).order_by('pk')[0] # Setup logging to Stdout - formatter = logging.Formatter(f'[%(asctime)s][%(levelname)s] - %(message)s') + formatter = logging.Formatter('[%(asctime)s][%(levelname)s] - %(message)s') stdouthandler = logging.StreamHandler(sys.stdout) stdouthandler.setLevel(logging.DEBUG) stdouthandler.setFormatter(formatter) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 889594902..8b7fc0cb6 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -283,7 +283,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): """ for ct in content_types: model = ct.model_class() - instances = model.objects.exclude(**{f'custom_field_data__contains': self.name}) + instances = model.objects.exclude(**{'custom_field_data__contains': self.name}) for instance in instances: instance.custom_field_data[self.name] = self.default model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100) @@ -661,7 +661,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): raise ValidationError(_("Value must be an integer.")) if self.validation_minimum is not None and value < self.validation_minimum: raise ValidationError( - _("Value must be at least {minimum}").format(minimum=self.validation_maximum) + _("Value must be at least {minimum}").format(minimum=self.validation_minimum) ) if self.validation_maximum is not None and value > self.validation_maximum: raise ValidationError( @@ -785,6 +785,12 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel def __str__(self): return self.name + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Cache the initial set of choices for comparison under clean() + self._original_extra_choices = self.__dict__.get('extra_choices') + def get_absolute_url(self): return reverse('extras:customfieldchoiceset', args=[self.pk]) @@ -818,6 +824,32 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel if not self.base_choices and not self.extra_choices: raise ValidationError(_("Must define base or extra choices.")) + # Check whether any choices have been removed. If so, check whether any of the removed + # choices are still set in custom field data for any object. + original_choices = set([ + c[0] for c in self._original_extra_choices + ]) if self._original_extra_choices else set() + current_choices = set([ + c[0] for c in self.extra_choices + ]) if self.extra_choices else set() + if removed_choices := original_choices - current_choices: + for custom_field in self.choices_for.all(): + for object_type in custom_field.object_types.all(): + model = object_type.model_class() + for choice in removed_choices: + # Form the query based on the type of custom field + if custom_field.type == CustomFieldTypeChoices.TYPE_MULTISELECT: + query_args = {f"custom_field_data__{custom_field.name}__contains": choice} + else: + query_args = {f"custom_field_data__{custom_field.name}": choice} + # Raise a ValidationError if there are any objects which still reference the removed choice + if model.objects.filter(models.Q(**query_args)).exists(): + raise ValidationError( + _( + "Cannot remove choice {choice} as there are {model} objects which reference it." + ).format(choice=choice, model=object_type) + ) + def save(self, *args, **kwargs): # Sort choices if alphabetical ordering is enforced diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 96d08d8af..f2bd75a1d 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -554,7 +554,7 @@ class BaseScript: """ Run the report and save its results. Each test method will be executed in order. """ - self.logger.info(f"Running report") + self.logger.info("Running report") try: for test_name in self.tests: diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 5defbd0bd..63baf44d3 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -12,7 +12,6 @@ from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Loca from extras.choices import * from extras.models import * from extras.scripts import BooleanVar, IntegerVar, Script as PythonClass, StringVar -from netbox.events import * from users.models import Group, User from utilities.testing import APITestCase, APIViewTestCases @@ -244,9 +243,18 @@ class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase): @classmethod def setUpTestData(cls): choice_sets = ( - CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['1A', '1B', '1C', '1D', '1E']), - CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['2A', '2B', '2C', '2D', '2E']), - CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['3A', '3B', '3C', '3D', '3E']), + CustomFieldChoiceSet( + name='Choice Set 1', + extra_choices=[['1A', '1A'], ['1B', '1B'], ['1C', '1C'], ['1D', '1D'], ['1E', '1E']], + ), + CustomFieldChoiceSet( + name='Choice Set 2', + extra_choices=[['2A', '2A'], ['2B', '2B'], ['2C', '2C'], ['2D', '2D'], ['2E', '2E']], + ), + CustomFieldChoiceSet( + name='Choice Set 3', + extra_choices=[['3A', '3A'], ['3B', '3B'], ['3C', '3C'], ['3D', '3D'], ['3E', '3E']], + ), ) CustomFieldChoiceSet.objects.bulk_create(choice_sets) @@ -784,7 +792,6 @@ class ScriptTest(APITestCase): super().setUp() # Monkey-patch the Script model to return our TestScriptClass above - from extras.api.views import ScriptViewSet Script.python_class = self.python_class def test_get_script(self): diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 697b756ec..2bc9b5acc 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -343,6 +343,74 @@ class CustomFieldTest(TestCase): instance.refresh_from_db() self.assertIsNone(instance.custom_field_data.get(cf.name)) + def test_remove_selected_choice(self): + """ + Removing a ChoiceSet choice that is referenced by an object should raise + a ValidationError exception. + """ + CHOICES = ( + ('a', 'Option A'), + ('b', 'Option B'), + ('c', 'Option C'), + ('d', 'Option D'), + ) + + # Create a set of custom field choices + choice_set = CustomFieldChoiceSet.objects.create( + name='Custom Field Choice Set 1', + extra_choices=CHOICES + ) + + # Create a select custom field + cf = CustomField.objects.create( + name='select_field', + type=CustomFieldTypeChoices.TYPE_SELECT, + required=False, + choice_set=choice_set + ) + cf.object_types.set([self.object_type]) + + # Create a multi-select custom field + cf_multiselect = CustomField.objects.create( + name='multiselect_field', + type=CustomFieldTypeChoices.TYPE_MULTISELECT, + required=False, + choice_set=choice_set + ) + cf_multiselect.object_types.set([self.object_type]) + + # Assign a choice for both custom fields on an object + instance = Site.objects.first() + instance.custom_field_data[cf.name] = 'a' + instance.custom_field_data[cf_multiselect.name] = ['b', 'c'] + instance.save() + + # Attempting to delete a selected choice should fail + with self.assertRaises(ValidationError): + choice_set.extra_choices = ( + ('b', 'Option B'), + ('c', 'Option C'), + ('d', 'Option D'), + ) + choice_set.full_clean() + + # Attempting to delete either of the multi-select choices should fail + with self.assertRaises(ValidationError): + choice_set.extra_choices = ( + ('a', 'Option A'), + ('b', 'Option B'), + ('d', 'Option D'), + ) + choice_set.full_clean() + + # Removing a non-selected choice should succeed + choice_set.extra_choices = ( + ('a', 'Option A'), + ('b', 'Option B'), + ('c', 'Option C'), + ) + choice_set.full_clean() + def test_object_field(self): value = VLAN.objects.create(name='VLAN 1', vid=1).pk diff --git a/netbox/extras/tests/test_customvalidators.py b/netbox/extras/tests/test_customvalidators.py index 49deb9da5..9f85b4913 100644 --- a/netbox/extras/tests/test_customvalidators.py +++ b/netbox/extras/tests/test_customvalidators.py @@ -162,7 +162,7 @@ class CustomValidatorTest(TestCase): Site(name='abcdef123', slug='abcdef123').clean() @override_settings(CUSTOM_VALIDATORS={'dcim.site': [region_validator]}) - def test_valid(self): + def test_related_object(self): region1 = Region(name='Foo', slug='foo') region1.save() region2 = Region(name='Bar', slug='bar') diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index c92a1bc54..188a06a3f 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -49,11 +49,11 @@ class ConfigContextTest(TestCase): sitegroup = SiteGroup.objects.create(name='Site Group') site = Site.objects.create(name='Site 1', slug='site-1', region=region, group=sitegroup) location = Location.objects.create(name='Location 1', slug='location-1', site=site) - platform = Platform.objects.create(name='Platform') + Platform.objects.create(name='Platform') tenantgroup = TenantGroup.objects.create(name='Tenant Group') - tenant = Tenant.objects.create(name='Tenant', group=tenantgroup) - tag1 = Tag.objects.create(name='Tag', slug='tag') - tag2 = Tag.objects.create(name='Tag2', slug='tag2') + Tenant.objects.create(name='Tenant', group=tenantgroup) + Tag.objects.create(name='Tag', slug='tag') + Tag.objects.create(name='Tag2', slug='tag2') Device.objects.create( name='Device 1', diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 796d36180..5d82fae4c 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -417,7 +417,7 @@ class EventRulesTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - f'name,object_types,event_types,action_type,action_object', + 'name,object_types,event_types,action_type,action_object', f'Webhook 4,dcim.site,"{OBJECT_CREATED},{OBJECT_UPDATED}",webhook,Webhook 1', ) diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index 28d2e13f0..efe7ada5b 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -33,7 +33,7 @@ def image_upload(instance, filename): # Rename the file to the provided name, if any. Attempt to preserve the file extension. extension = filename.rsplit('.')[-1].lower() - if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png']: + if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp']: filename = '.'.join([instance.name, extension]) elif instance.name: filename = instance.name diff --git a/netbox/extras/validators.py b/netbox/extras/validators.py index 6823c1978..306acf01b 100644 --- a/netbox/extras/validators.py +++ b/netbox/extras/validators.py @@ -1,4 +1,3 @@ -import inspect import operator from django.core import validators diff --git a/netbox/extras/views.py b/netbox/extras/views.py index b3d942fd8..0d98b1324 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -6,8 +6,8 @@ from django.db.models import Count, Q from django.http import HttpResponseBadRequest, HttpResponseForbidden, HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse -from django.utils.module_loading import import_string from django.utils import timezone +from django.utils.module_loading import import_string from django.utils.translation import gettext as _ from django.views.generic import View @@ -20,7 +20,6 @@ from extras.choices import LogLevelChoices from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm from extras.dashboard.utils import get_widget_class from netbox.constants import DEFAULT_ACTION_PERMISSIONS -from netbox.registry import registry from netbox.views import generic from netbox.views.generic.mixins import TableMixin from utilities.forms import ConfirmationForm, get_field_value @@ -1181,7 +1180,8 @@ class ScriptView(BaseScriptView): data=form.cleaned_data, request=copy_safe_request(request), job_timeout=script.python_class.job_timeout, - commit=form.cleaned_data.pop('_commit') + commit=form.cleaned_data.pop('_commit'), + name=script.name ) return redirect('extras:script_result', job_pk=job.pk) diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 57a1a65d5..8b10f29df 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -30,7 +30,7 @@ __all__ = [ # TODO: Remove in v4.2 warnings.warn( - f"Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.", + "Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.", DeprecationWarning ) diff --git a/netbox/ipam/api/serializers_/roles.py b/netbox/ipam/api/serializers_/roles.py index 9a97a8570..99fd6f470 100644 --- a/netbox/ipam/api/serializers_/roles.py +++ b/netbox/ipam/api/serializers_/roles.py @@ -1,5 +1,3 @@ -from rest_framework import serializers - from ipam.models import Role from netbox.api.fields import RelatedObjectCountField from netbox.api.serializers import NetBoxModelSerializer diff --git a/netbox/ipam/api/serializers_/services.py b/netbox/ipam/api/serializers_/services.py index e0b2014f6..61b330d01 100644 --- a/netbox/ipam/api/serializers_/services.py +++ b/netbox/ipam/api/serializers_/services.py @@ -1,5 +1,3 @@ -from rest_framework import serializers - from dcim.api.serializers_.devices import DeviceSerializer from ipam.choices import * from ipam.models import IPAddress, Service, ServiceTemplate diff --git a/netbox/ipam/api/serializers_/vrfs.py b/netbox/ipam/api/serializers_/vrfs.py index ad54dc095..a23909108 100644 --- a/netbox/ipam/api/serializers_/vrfs.py +++ b/netbox/ipam/api/serializers_/vrfs.py @@ -1,5 +1,3 @@ -from rest_framework import serializers - from ipam.models import RouteTarget, VRF from netbox.api.fields import RelatedObjectCountField, SerializedPKRelatedField from netbox.api.serializers import NetBoxModelSerializer diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index cac90bb87..ffd4d5b7d 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -186,13 +186,13 @@ class AvailableObjectsView(ObjectValidationMixin, APIView): """ Return the parent object. """ - raise NotImplemented() + raise NotImplementedError() def get_available_objects(self, parent, limit=None): """ Return all available objects for the parent. """ - raise NotImplemented() + raise NotImplementedError() def get_extra_context(self, parent): """ @@ -250,7 +250,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView): # Determine if the requested number of objects is available if not self.check_sufficient_available(serializer.validated_data, available_objects): return Response( - {"detail": f"Insufficient resources are available to satisfy the request"}, + {"detail": "Insufficient resources are available to satisfy the request"}, status=status.HTTP_409_CONFLICT ) diff --git a/netbox/ipam/apps.py b/netbox/ipam/apps.py index 244ec7d6d..c118d5464 100644 --- a/netbox/ipam/apps.py +++ b/netbox/ipam/apps.py @@ -7,7 +7,7 @@ class IPAMConfig(AppConfig): def ready(self): from netbox.models.features import register_models - from . import signals, search + from . import signals, search # noqa: F401 # Register models register_models(*self.get_models()) diff --git a/netbox/ipam/fields.py b/netbox/ipam/fields.py index 20341005d..a82976326 100644 --- a/netbox/ipam/fields.py +++ b/netbox/ipam/fields.py @@ -105,6 +105,8 @@ IPAddressField.register_lookup(lookups.NetIn) IPAddressField.register_lookup(lookups.NetHostContained) IPAddressField.register_lookup(lookups.NetFamily) IPAddressField.register_lookup(lookups.NetMaskLength) +IPAddressField.register_lookup(lookups.Host) +IPAddressField.register_lookup(lookups.Inet) class ASNField(models.BigIntegerField): diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index f98f510e5..894219c64 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -1035,6 +1035,16 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): to_field_name='identifier', label=_('L2VPN'), ) + interface_id = django_filters.ModelChoiceFilter( + queryset=Interface.objects.all(), + method='filter_interface_id', + label=_('Assigned interface') + ) + vminterface_id = django_filters.ModelChoiceFilter( + queryset=VMInterface.objects.all(), + method='filter_vminterface_id', + label=_('Assigned VM interface') + ) class Meta: model = VLAN @@ -1062,6 +1072,22 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): def get_for_virtualmachine(self, queryset, name, value): return queryset.get_for_virtualmachine(value) + def filter_interface_id(self, queryset, name, value): + if value is None: + return queryset.none() + return queryset.filter( + Q(interfaces_as_tagged=value) | + Q(interfaces_as_untagged=value) + ) + + def filter_vminterface_id(self, queryset, name, value): + if value is None: + return queryset.none() + return queryset.filter( + Q(vminterfaces_as_tagged=value) | + Q(vminterfaces_as_untagged=value) + ) + class ServiceTemplateFilterSet(NetBoxModelFilterSet): port = NumericArrayFilter( diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index f4a7eabb7..79debd0ed 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -1,22 +1,23 @@ from django import forms from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist from django.utils.translation import gettext_lazy as _ -from dcim.models import Location, Rack, Region, Site, SiteGroup +from dcim.models import Region, Site, SiteGroup from ipam.choices import * from ipam.constants import * from ipam.models import * from ipam.models import ASN from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant -from utilities.forms import add_blank_choice +from utilities.forms import add_blank_choice, get_field_value from utilities.forms.fields import ( CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField, NumericRangeArrayField, ) from utilities.forms.rendering import FieldSet -from utilities.forms.widgets import BulkEditNullBooleanSelect -from virtualization.models import Cluster, ClusterGroup +from utilities.forms.widgets import BulkEditNullBooleanSelect, HTMXSelect +from utilities.templatetags.builtins.filters import bettertitle __all__ = ( 'AggregateBulkEditForm', @@ -429,62 +430,17 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): required=False ) scope_type = ContentTypeChoiceField( - label=_('Scope type'), queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), - required=False - ) - scope_id = forms.IntegerField( + widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}), required=False, - widget=forms.HiddenInput() + label=_('Scope type') ) - region = DynamicModelChoiceField( - label=_('Region'), - queryset=Region.objects.all(), - required=False - ) - sitegroup = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), + scope = DynamicModelChoiceField( + label=_('Scope'), + queryset=Site.objects.none(), # Initial queryset required=False, - label=_('Site group') - ) - site = DynamicModelChoiceField( - label=_('Site'), - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region', - 'group_id': '$sitegroup', - } - ) - location = DynamicModelChoiceField( - label=_('Location'), - queryset=Location.objects.all(), - required=False, - query_params={ - 'site_id': '$site', - } - ) - rack = DynamicModelChoiceField( - label=_('Rack'), - queryset=Rack.objects.all(), - required=False, - query_params={ - 'site_id': '$site', - 'location_id': '$location', - } - ) - clustergroup = DynamicModelChoiceField( - queryset=ClusterGroup.objects.all(), - required=False, - label=_('Cluster group') - ) - cluster = DynamicModelChoiceField( - label=_('Cluster'), - queryset=Cluster.objects.all(), - required=False, - query_params={ - 'group_id': '$clustergroup', - } + disabled=True, + selector=True ) vid_ranges = NumericRangeArrayField( label=_('VLAN ID ranges'), @@ -494,24 +450,23 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): model = VLANGroup fieldsets = ( FieldSet('site', 'vid_ranges', 'description'), - FieldSet( - 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster', name=_('Scope') - ), + FieldSet('scope_type', 'scope', name=_('Scope')), ) - nullable_fields = ('description',) + nullable_fields = ('description', 'scope') - def clean(self): - super().clean() + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) - # Assign scope based on scope_type - if self.cleaned_data.get('scope_type'): - scope_field = self.cleaned_data['scope_type'].model - if scope_obj := self.cleaned_data.get(scope_field): - self.cleaned_data['scope_id'] = scope_obj.pk - self.changed_data.append('scope_id') - else: - self.cleaned_data.pop('scope_type') - self.changed_data.remove('scope_type') + if scope_type_id := get_field_value(self, 'scope_type'): + try: + scope_type = ContentType.objects.get(pk=scope_type_id) + model = scope_type.model_class() + self.fields['scope'].queryset = model.objects.all() + self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower + self.fields['scope'].disabled = False + self.fields['scope'].label = _(bettertitle(model._meta.verbose_name)) + except ObjectDoesNotExist: + pass class VLANBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/ipam/graphql/mixins.py b/netbox/ipam/graphql/mixins.py index 73cc60ec4..692741871 100644 --- a/netbox/ipam/graphql/mixins.py +++ b/netbox/ipam/graphql/mixins.py @@ -1,7 +1,6 @@ from typing import Annotated, List import strawberry -import strawberry_django __all__ = ( 'IPAddressesMixin', @@ -11,9 +10,9 @@ __all__ = ( @strawberry.type class IPAddressesMixin: - ip_addresses: List[Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')]] + ip_addresses: List[Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')]] # noqa: F821 @strawberry.type class VLANGroupsMixin: - vlan_groups: List[Annotated["VLANGroupType", strawberry.lazy('ipam.graphql.types')]] + vlan_groups: List[Annotated["VLANGroupType", strawberry.lazy('ipam.graphql.types')]] # noqa: F821 diff --git a/netbox/ipam/models/asns.py b/netbox/ipam/models/asns.py index ba483a745..d02efd91c 100644 --- a/netbox/ipam/models/asns.py +++ b/netbox/ipam/models/asns.py @@ -149,3 +149,7 @@ class ASN(PrimaryModel): return f'{self.asn} ({self.asn // 65536}.{self.asn % 65536})' else: return self.asn + + @property + def prefixed_name(self): + return f'AS{self.asn_with_asdot}' diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 0b8e3a8df..868b92450 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -580,15 +580,15 @@ class IPRange(ContactsMixin, PrimaryModel): }) # Check for overlapping ranges - overlapping_range = IPRange.objects.exclude(pk=self.pk).filter(vrf=self.vrf).filter( - Q(start_address__gte=self.start_address, start_address__lte=self.end_address) | # Starts inside - Q(end_address__gte=self.start_address, end_address__lte=self.end_address) | # Ends inside - Q(start_address__lte=self.start_address, end_address__gte=self.end_address) # Starts & ends outside - ).first() - if overlapping_range: + overlapping_ranges = IPRange.objects.exclude(pk=self.pk).filter(vrf=self.vrf).filter( + Q(start_address__host__inet__gte=self.start_address.ip, start_address__host__inet__lte=self.end_address.ip) | # Starts inside + Q(end_address__host__inet__gte=self.start_address.ip, end_address__host__inet__lte=self.end_address.ip) | # Ends inside + Q(start_address__host__inet__lte=self.start_address.ip, end_address__host__inet__gte=self.end_address.ip) # Starts & ends outside + ) + if overlapping_ranges.exists(): raise ValidationError( _("Defined addresses overlap with range {overlapping_range} in VRF {vrf}").format( - overlapping_range=overlapping_range, + overlapping_range=overlapping_ranges.first(), vrf=self.vrf )) diff --git a/netbox/ipam/search.py b/netbox/ipam/search.py index 59b741b8f..16a8eba3c 100644 --- a/netbox/ipam/search.py +++ b/netbox/ipam/search.py @@ -19,6 +19,7 @@ class ASNIndex(SearchIndex): model = models.ASN fields = ( ('asn', 100), + ('prefixed_name', 110), ('description', 500), ) display_attrs = ('rir', 'tenant', 'description') @@ -28,6 +29,7 @@ class ASNIndex(SearchIndex): class ASNRangeIndex(SearchIndex): model = models.ASNRange fields = ( + ('name', 100), ('description', 500), ) display_attrs = ('rir', 'tenant', 'description') diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 4152efefb..8ec7a5967 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -51,7 +51,7 @@ IPADDRESS_LINK = """ {% if record.pk %} {{ record.address }} {% elif perms.ipam.add_ipaddress %} - {% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available + {% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available {% else %} {% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available {% endif %} diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 59335cbbe..1d2cdf1b7 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -700,8 +700,6 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase): device1.primary_ip4 = ip_addresses[0] device1.save() - ip2 = ip_addresses[1] - url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': ip1.pk}) self.add_permissions('ipam.change_ipaddress') diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index e149c0a8d..4e38b1450 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -1658,6 +1658,13 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): ) Device.objects.bulk_create(devices) + interfaces = ( + Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[1], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[2], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + ) + Interface.objects.bulk_create(interfaces) + cluster_groups = ( ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'), ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'), @@ -1680,6 +1687,13 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): ) VirtualMachine.objects.bulk_create(virtual_machines) + vm_interfaces = ( + VMInterface(virtual_machine=virtual_machines[0], name='VM Interface 1'), + VMInterface(virtual_machine=virtual_machines[1], name='VM Interface 2'), + VMInterface(virtual_machine=virtual_machines[2], name='VM Interface 3'), + ) + VMInterface.objects.bulk_create(vm_interfaces) + groups = ( # Scoped VLAN groups VLANGroup(name='Region 1', slug='region-1', scope=regions[0]), @@ -1773,6 +1787,22 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): ) VLAN.objects.bulk_create(vlans) + # Assign VLANs to device interfaces + interfaces[0].untagged_vlan = vlans[0] + interfaces[0].tagged_vlans.add(vlans[1]) + interfaces[1].untagged_vlan = vlans[2] + interfaces[1].tagged_vlans.add(vlans[3]) + interfaces[2].untagged_vlan = vlans[4] + interfaces[2].tagged_vlans.add(vlans[5]) + + # Assign VLANs to VM interfaces + vm_interfaces[0].untagged_vlan = vlans[0] + vm_interfaces[0].tagged_vlans.add(vlans[1]) + vm_interfaces[1].untagged_vlan = vlans[2] + vm_interfaces[1].tagged_vlans.add(vlans[3]) + vm_interfaces[2].untagged_vlan = vlans[4] + vm_interfaces[2].tagged_vlans.add(vlans[5]) + def test_q(self): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -1857,6 +1887,16 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'available_at_site': site_id} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) # 4 scoped + 1 global group + 1 global + def test_interface(self): + interface_id = Interface.objects.first().pk + params = {'interface_id': interface_id} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_vminterface(self): + vminterface_id = VMInterface.objects.first().pk + params = {'vminterface_id': vminterface_id} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ServiceTemplate.objects.all() diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index 8a5d918a9..d14fa0657 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -36,6 +36,35 @@ class TestAggregate(TestCase): self.assertEqual(aggregate.get_utilization(), 100) +class TestIPRange(TestCase): + + def test_overlapping_range(self): + iprange_192_168 = IPRange.objects.create(start_address=IPNetwork('192.168.0.1/22'), end_address=IPNetwork('192.168.0.49/22')) + iprange_192_168.clean() + iprange_3_1_99 = IPRange.objects.create(start_address=IPNetwork('1.2.3.1/24'), end_address=IPNetwork('1.2.3.99/24')) + iprange_3_1_99.clean() + iprange_3_100_199 = IPRange.objects.create(start_address=IPNetwork('1.2.3.100/24'), end_address=IPNetwork('1.2.3.199/24')) + iprange_3_100_199.clean() + iprange_3_200_255 = IPRange.objects.create(start_address=IPNetwork('1.2.3.200/24'), end_address=IPNetwork('1.2.3.255/24')) + iprange_3_200_255.clean() + iprange_4_1_99 = IPRange.objects.create(start_address=IPNetwork('1.2.4.1/24'), end_address=IPNetwork('1.2.4.99/24')) + iprange_4_1_99.clean() + iprange_4_200 = IPRange.objects.create(start_address=IPNetwork('1.2.4.200/24'), end_address=IPNetwork('1.2.4.255/24')) + iprange_4_200.clean() + # Overlapping range entirely within existing + with self.assertRaises(ValidationError): + iprange_3_123_124 = IPRange.objects.create(start_address=IPNetwork('1.2.3.123/26'), end_address=IPNetwork('1.2.3.124/26')) + iprange_3_123_124.clean() + # Overlapping range starting within existing + with self.assertRaises(ValidationError): + iprange_4_98_101 = IPRange.objects.create(start_address=IPNetwork('1.2.4.98/24'), end_address=IPNetwork('1.2.4.101/24')) + iprange_4_98_101.clean() + # Overlapping range ending within existing + with self.assertRaises(ValidationError): + iprange_4_198_201 = IPRange.objects.create(start_address=IPNetwork('1.2.4.198/24'), end_address=IPNetwork('1.2.4.201/24')) + iprange_4_198_201.clean() + + class TestPrefix(TestCase): def test_get_duplicates(self): diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 2acb80ac1..95b311878 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -50,7 +50,7 @@ class ASNRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - f"name,slug,rir,tenant,start,end,description", + "name,slug,rir,tenant,start,end,description", f"ASN Range 4,asn-range-4,{rirs[1].name},{tenants[1].name},400,499,Fourth range", f"ASN Range 5,asn-range-5,{rirs[1].name},{tenants[1].name},500,599,Fifth range", f"ASN Range 6,asn-range-6,{rirs[1].name},{tenants[1].name},600,699,Sixth range", @@ -770,14 +770,14 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): } cls.csv_data = ( - f"name,slug,scope_type,scope_id,description", - f"VLAN Group 4,vlan-group-4,,,Fourth VLAN group", + "name,slug,scope_type,scope_id,description", + "VLAN Group 4,vlan-group-4,,,Fourth VLAN group", f"VLAN Group 5,vlan-group-5,dcim.site,{sites[0].pk},Fifth VLAN group", f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].pk},Sixth VLAN group", ) cls.csv_update_data = ( - f"id,name,description", + "id,name,description", f"{vlan_groups[0].pk},VLAN Group 7,Fourth VLAN group7", f"{vlan_groups[1].pk},VLAN Group 8,Fifth VLAN group8", f"{vlan_groups[2].pk},VLAN Group 9,Sixth VLAN group9", diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 67d56f15e..a2df4bcf2 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -3,7 +3,7 @@ from django.db.models import Prefetch from django.db.models.expressions import RawSQL from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from circuits.models import Provider from dcim.filtersets import InterfaceFilterSet diff --git a/netbox/manage.py b/netbox/manage.py index 2ce3867f3..737592ae8 100755 --- a/netbox/manage.py +++ b/netbox/manage.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import os import sys diff --git a/netbox/netbox/api/serializers/nested.py b/netbox/netbox/api/serializers/nested.py index 04033c71f..4a5fc6214 100644 --- a/netbox/netbox/api/serializers/nested.py +++ b/netbox/netbox/api/serializers/nested.py @@ -1,5 +1,3 @@ -from rest_framework import serializers - from extras.models import Tag from utilities.api import get_related_object_by_attrs from .base import BaseModelSerializer diff --git a/netbox/netbox/authentication/__init__.py b/netbox/netbox/authentication/__init__.py index 7394f6ded..7c2df4200 100644 --- a/netbox/netbox/authentication/__init__.py +++ b/netbox/netbox/authentication/__init__.py @@ -20,10 +20,10 @@ AUTH_BACKEND_ATTRS = { 'amazon': ('Amazon AWS', 'aws'), 'apple': ('Apple', 'apple'), 'auth0': ('Auth0', None), - 'azuread-oauth2': ('Microsoft Azure AD', 'microsoft'), - 'azuread-b2c-oauth2': ('Microsoft Azure AD', 'microsoft'), - 'azuread-tenant-oauth2': ('Microsoft Azure AD', 'microsoft'), - 'azuread-v2-tenant-oauth2': ('Microsoft Azure AD', 'microsoft'), + 'azuread-oauth2': ('Microsoft Entra ID', 'microsoft'), + 'azuread-b2c-oauth2': ('Microsoft Entra ID', 'microsoft'), + 'azuread-tenant-oauth2': ('Microsoft Entra ID', 'microsoft'), + 'azuread-v2-tenant-oauth2': ('Microsoft Entra ID', 'microsoft'), 'bitbucket': ('BitBucket', 'bitbucket'), 'bitbucket-oauth2': ('BitBucket', 'bitbucket'), 'digitalocean': ('DigitalOcean', 'digital-ocean'), diff --git a/netbox/netbox/config/__init__.py b/netbox/netbox/config/__init__.py index 1c16d6769..23108f1d2 100644 --- a/netbox/netbox/config/__init__.py +++ b/netbox/netbox/config/__init__.py @@ -85,7 +85,7 @@ class Config: logger.debug("Loaded configuration data from database") except DatabaseError: # The database may not be available yet (e.g. when running a management command) - logger.warning(f"Skipping config initialization (database unavailable)") + logger.warning("Skipping config initialization (database unavailable)") return revision.activate() diff --git a/netbox/netbox/data_backends.py b/netbox/netbox/data_backends.py index d5bab75c1..e3a3de4d1 100644 --- a/netbox/netbox/data_backends.py +++ b/netbox/netbox/data_backends.py @@ -50,4 +50,4 @@ class DataBackend: 2. Yields the local path at which data has been replicated 3. Performs any necessary cleanup """ - raise NotImplemented() + raise NotImplementedError() diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index ac43fe57f..637a40bf1 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -133,7 +133,7 @@ class BaseFilterSet(django_filters.FilterSet): django_filters.ModelChoiceFilter, django_filters.ModelMultipleChoiceFilter, TagFilter - )) or existing_filter.extra.get('choices'): + )): # These filter types support only negation return FILTER_NEGATION_LOOKUP_MAP @@ -172,6 +172,7 @@ class BaseFilterSet(django_filters.FilterSet): # Create new filters for each lookup expression in the map for lookup_name, lookup_expr in lookup_map.items(): new_filter_name = f'{existing_filter_name}__{lookup_name}' + existing_filter_extra = deepcopy(existing_filter.extra) try: if existing_filter_name in cls.declared_filters: @@ -179,14 +180,18 @@ class BaseFilterSet(django_filters.FilterSet): # create the new filter with the same type because there is no guarantee the defined type # is the same as the default type for the field resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid - filter_cls = django_filters.BooleanFilter if lookup_expr == 'empty' else type(existing_filter) + filter_cls = type(existing_filter) + if lookup_expr == 'empty': + filter_cls = django_filters.BooleanFilter + for param_to_remove in ('choices', 'null_value'): + existing_filter_extra.pop(param_to_remove, None) new_filter = filter_cls( field_name=field_name, lookup_expr=lookup_expr, label=existing_filter.label, exclude=existing_filter.exclude, distinct=existing_filter.distinct, - **existing_filter.extra + **existing_filter_extra ) elif hasattr(existing_filter, 'custom_field'): # Filter is for a custom field diff --git a/netbox/netbox/graphql/filter_mixins.py b/netbox/netbox/graphql/filter_mixins.py index 65c7ffcef..2044a1dde 100644 --- a/netbox/netbox/graphql/filter_mixins.py +++ b/netbox/netbox/graphql/filter_mixins.py @@ -1,11 +1,12 @@ -from functools import partial, partialmethod, wraps +from functools import partialmethod from typing import List import django_filters import strawberry import strawberry_django -from django.core.exceptions import FieldDoesNotExist, ValidationError +from django.core.exceptions import FieldDoesNotExist from strawberry import auto + from ipam.fields import ASNField from netbox.graphql.scalars import BigInt from utilities.fields import ColorField, CounterCacheField @@ -108,8 +109,7 @@ def map_strawberry_type(field): elif issubclass(type(field), django_filters.TypedMultipleChoiceFilter): pass elif issubclass(type(field), django_filters.MultipleChoiceFilter): - should_create_function = True - attr_type = List[str] | None + attr_type = str | None elif issubclass(type(field), django_filters.TypedChoiceFilter): pass elif issubclass(type(field), django_filters.ChoiceFilter): diff --git a/netbox/netbox/graphql/views.py b/netbox/netbox/graphql/views.py index b347d71b4..46a073c93 100644 --- a/netbox/netbox/graphql/views.py +++ b/netbox/netbox/graphql/views.py @@ -1,10 +1,6 @@ -import json - from django.conf import settings from django.contrib.auth.views import redirect_to_login from django.http import HttpResponseNotFound, HttpResponseForbidden -from django.http import HttpResponse -from django.template import loader from django.urls import reverse from django.views.decorators.csrf import csrf_exempt from rest_framework.exceptions import AuthenticationFailed @@ -18,7 +14,6 @@ class NetBoxGraphQLView(GraphQLView): """ Extends strawberry's GraphQLView to support DRF's token-based authentication. """ - graphiql_template = 'graphiql.html' @csrf_exempt def dispatch(self, request, *args, **kwargs): @@ -46,9 +41,3 @@ class NetBoxGraphQLView(GraphQLView): return HttpResponseForbidden("No credentials provided.") return super().dispatch(request, *args, **kwargs) - - def render_graphql_ide(self, request): - template = loader.get_template("graphiql.html") - context = {"SUBSCRIPTION_ENABLED": json.dumps(self.subscriptions_enabled)} - - return HttpResponse(template.render(context, request)) diff --git a/netbox/netbox/jobs.py b/netbox/netbox/jobs.py index 087c24896..ae8f2f109 100644 --- a/netbox/netbox/jobs.py +++ b/netbox/netbox/jobs.py @@ -68,6 +68,8 @@ class JobRunner(ABC): finally: if job.interval: new_scheduled_time = (job.scheduled or job.started) + timedelta(minutes=job.interval) + if job.object and getattr(job.object, "python_class", None): + kwargs["job_timeout"] = job.object.python_class.job_timeout cls.enqueue( instance=job.object, user=job.user, diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index 4ba5f60da..aea5f00cc 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -162,7 +162,7 @@ class NestedGroupModel(NetBoxFeatureSet, MPTTModel): super().clean() # An MPTT model cannot be its own parent - if self.pk and self.parent and self.parent in self.get_descendants(include_self=True): + if not self._state.adding and self.parent and self.parent in self.get_descendants(include_self=True): raise ValidationError({ "parent": "Cannot assign self or child {type} as parent.".format(type=self._meta.verbose_name) }) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 45eb70081..a97227770 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -408,14 +408,9 @@ class JobsMixin(models.Model): def get_latest_jobs(self): """ - Return a dictionary mapping of the most recent jobs for this instance. + Return a list of the most recent jobs for this instance. """ - return { - job.name: job - for job in self.jobs.filter( - status__in=JobStatusChoices.TERMINAL_STATE_CHOICES - ).order_by('name', '-created').distinct('name').defer('data') - } + return self.jobs.filter(status__in=JobStatusChoices.TERMINAL_STATE_CHOICES).order_by('-created').defer('data') class JournalingMixin(models.Model): diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index a1d65d6e2..9d8ffaaf8 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -386,57 +386,57 @@ ADMIN_MENU = Menu( label=_('Authentication'), items=( MenuItem( - link=f'users:user_list', + link='users:user_list', link_text=_('Users'), auth_required=True, - permissions=[f'users.view_user'], + permissions=['users.view_user'], buttons=( MenuItemButton( - link=f'users:user_add', + link='users:user_add', title='Add', icon_class='mdi mdi-plus-thick', - permissions=[f'users.add_user'] + permissions=['users.add_user'] ), MenuItemButton( - link=f'users:user_import', + link='users:user_import', title='Import', icon_class='mdi mdi-upload', - permissions=[f'users.add_user'] + permissions=['users.add_user'] ) ) ), MenuItem( - link=f'users:group_list', + link='users:group_list', link_text=_('Groups'), auth_required=True, - permissions=[f'users.view_group'], + permissions=['users.view_group'], buttons=( MenuItemButton( - link=f'users:group_add', + link='users:group_add', title='Add', icon_class='mdi mdi-plus-thick', - permissions=[f'users.add_group'] + permissions=['users.add_group'] ), MenuItemButton( - link=f'users:group_import', + link='users:group_import', title='Import', icon_class='mdi mdi-upload', - permissions=[f'users.add_group'] + permissions=['users.add_group'] ) ) ), MenuItem( - link=f'users:token_list', + link='users:token_list', link_text=_('API Tokens'), auth_required=True, - permissions=[f'users.view_token'], + permissions=['users.view_token'], buttons=get_model_buttons('users', 'token') ), MenuItem( - link=f'users:objectpermission_list', + link='users:objectpermission_list', link_text=_('Permissions'), auth_required=True, - permissions=[f'users.view_objectpermission'], + permissions=['users.view_objectpermission'], buttons=get_model_buttons('users', 'objectpermission', actions=['add']) ), ), diff --git a/netbox/netbox/plugins/urls.py b/netbox/netbox/plugins/urls.py index 075bda811..deb20732f 100644 --- a/netbox/netbox/plugins/urls.py +++ b/netbox/netbox/plugins/urls.py @@ -3,7 +3,6 @@ from importlib import import_module from django.apps import apps from django.conf import settings from django.conf.urls import include -from django.contrib.admin.views.decorators import staff_member_required from django.urls import path from django.utils.module_loading import import_string, module_has_submodule diff --git a/netbox/netbox/plugins/views.py b/netbox/netbox/plugins/views.py index 777a4c69e..6a10f2e2c 100644 --- a/netbox/netbox/plugins/views.py +++ b/netbox/netbox/plugins/views.py @@ -2,9 +2,7 @@ from collections import OrderedDict from django.apps import apps from django.conf import settings -from django.shortcuts import render from django.urls.exceptions import NoReverseMatch -from django.views.generic import View from drf_spectacular.utils import extend_schema from rest_framework import permissions from rest_framework.response import Response diff --git a/netbox/netbox/preferences.py b/netbox/netbox/preferences.py index d911aabb0..4fdb7e31f 100644 --- a/netbox/netbox/preferences.py +++ b/netbox/netbox/preferences.py @@ -1,5 +1,5 @@ from django.conf import settings -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from netbox.registry import registry from users.preferences import UserPreference diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 358f41ff8..a8ac68d4d 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -5,14 +5,12 @@ import os import platform import sys import warnings -from urllib.parse import urlencode, urlsplit +from urllib.parse import urlencode -import django import requests from django.contrib.messages import constants as messages from django.core.exceptions import ImproperlyConfigured, ValidationError from django.core.validators import URLValidator -from django.utils.encoding import force_str from django.utils.translation import gettext_lazy as _ from netbox.config import PARAMS as CONFIG_PARAMS @@ -200,7 +198,7 @@ if len(SECRET_KEY) < 50: if RELEASE_CHECK_URL: try: URLValidator()(RELEASE_CHECK_URL) - except ValidationError as e: + except ValidationError: raise ImproperlyConfigured( "RELEASE_CHECK_URL must be a valid URL. Example: https://api.github.com/repos/netbox-community/netbox" ) @@ -252,7 +250,7 @@ if STORAGE_BACKEND is not None: # django-storage-swift elif STORAGE_BACKEND == 'swift.storage.SwiftStorage': try: - import swift.utils # type: ignore + import swift.utils # noqa: F401 except ModuleNotFoundError as e: if getattr(e, 'name') == 'swift': raise ImproperlyConfigured( diff --git a/netbox/netbox/staging.py b/netbox/netbox/staging.py index 4d37fb7ad..e6b946403 100644 --- a/netbox/netbox/staging.py +++ b/netbox/netbox/staging.py @@ -80,7 +80,7 @@ class checkout: Create Change instances for all actions stored in the queue. """ if not self.queue: - logger.debug(f"No queued changes; aborting") + logger.debug("No queued changes; aborting") return logger.debug(f"Processing {len(self.queue)} queued changes") diff --git a/netbox/netbox/tests/dummy_plugin/views.py b/netbox/netbox/tests/dummy_plugin/views.py index f6cf6a5c5..82f250fc1 100644 --- a/netbox/netbox/tests/dummy_plugin/views.py +++ b/netbox/netbox/tests/dummy_plugin/views.py @@ -8,7 +8,7 @@ from dcim.models import Site from utilities.views import register_model_view from .models import DummyModel # Trigger registration of custom column -from .tables import mycol +from .tables import mycol # noqa: F401 class DummyModelsView(View): @@ -21,7 +21,7 @@ class DummyModelsView(View): class DummyModelAddView(View): def get(self, request): - return HttpResponse(f"Create an instance") + return HttpResponse("Create an instance") def post(self, request): instance = DummyModel( @@ -29,7 +29,7 @@ class DummyModelAddView(View): number=random.randint(1, 100000) ) instance.save() - return HttpResponse(f"Instance created") + return HttpResponse("Instance created") @register_model_view(Site, 'extra', path='other-stuff') diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 5c7a30dc7..ae6d3f4c2 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -106,7 +106,7 @@ class ExternalAuthenticationTestCase(TestCase): self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_REMOTE_USER') # Client should not be authenticated - response = self.client.get(reverse('home'), follow=True, **headers) + self.client.get(reverse('home'), follow=True, **headers) self.assertNotIn('_auth_user_id', self.client.session) @override_settings( diff --git a/netbox/netbox/tests/test_graphql.py b/netbox/netbox/tests/test_graphql.py index 34ea3ad6a..b04d42d24 100644 --- a/netbox/netbox/tests/test_graphql.py +++ b/netbox/netbox/tests/test_graphql.py @@ -5,8 +5,8 @@ from django.urls import reverse from rest_framework import status from core.models import ObjectType +from dcim.choices import LocationStatusChoices from dcim.models import Site, Location -from ipam.models import ASN, RIR from users.models import ObjectPermission from utilities.testing import disable_warnings, APITestCase, TestCase @@ -53,10 +53,27 @@ class GraphQLAPITestCase(APITestCase): sites = ( Site(name='Site 1', slug='site-1'), Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), ) Site.objects.bulk_create(sites) - Location.objects.create(site=sites[0], name='Location 1', slug='location-1'), - Location.objects.create(site=sites[1], name='Location 2', slug='location-2'), + Location.objects.create( + site=sites[0], + name='Location 1', + slug='location-1', + status=LocationStatusChoices.STATUS_PLANNED + ), + Location.objects.create( + site=sites[1], + name='Location 2', + slug='location-2', + status=LocationStatusChoices.STATUS_STAGING + ), + Location.objects.create( + site=sites[1], + name='Location 3', + slug='location-3', + status=LocationStatusChoices.STATUS_ACTIVE + ), # Add object-level permission obj_perm = ObjectPermission( @@ -68,8 +85,9 @@ class GraphQLAPITestCase(APITestCase): obj_perm.object_types.add(ObjectType.objects.get_for_model(Location)) obj_perm.object_types.add(ObjectType.objects.get_for_model(Site)) - # A valid request should return the filtered list url = reverse('graphql') + + # A valid request should return the filtered list query = '{location_list(filters: {site_id: "' + str(sites[0].pk) + '"}) {id site {id}}}' response = self.client.post(url, data={'query': query}, format="json", **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) @@ -78,6 +96,21 @@ class GraphQLAPITestCase(APITestCase): self.assertEqual(len(data['data']['location_list']), 1) self.assertIsNotNone(data['data']['location_list'][0]['site']) + # Test OR logic + query = """{ + location_list( filters: { + status: \"""" + LocationStatusChoices.STATUS_PLANNED + """\", + OR: {status: \"""" + LocationStatusChoices.STATUS_STAGING + """\"} + }) { + id site {id} + } + }""" + response = self.client.post(url, data={'query': query}, format="json", **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + data = json.loads(response.content) + self.assertNotIn('errors', data) + self.assertEqual(len(data['data']['location_list']), 2) + # An invalid request should return an empty list query = '{location_list(filters: {site_id: "99999"}) {id site {id}}}' # Invalid site ID response = self.client.post(url, data={'query': query}, format="json", **self.header) diff --git a/netbox/netbox/tests/test_import.py b/netbox/netbox/tests/test_import.py index 03690029c..16711ef72 100644 --- a/netbox/netbox/tests/test_import.py +++ b/netbox/netbox/tests/test_import.py @@ -77,7 +77,6 @@ class CSVImportTestCase(ModelViewTestCase): self.assertHttpStatus(self.client.post(self._get_url('import'), data), 302) regions = Region.objects.all() self.assertEqual(regions.count(), 4) - region = Region.objects.get(slug="region-4") self.assertEqual( list(regions[0].tags.values_list('name', flat=True)), ['Alpha', 'Bravo'] diff --git a/netbox/netbox/tests/test_jobs.py b/netbox/netbox/tests/test_jobs.py index cb3024038..52a7bd97a 100644 --- a/netbox/netbox/tests/test_jobs.py +++ b/netbox/netbox/tests/test_jobs.py @@ -5,7 +5,7 @@ from django.utils import timezone from django_rq import get_queue from ..jobs import * -from core.models import Job +from core.models import DataSource, Job from core.choices import JobStatusChoices @@ -68,7 +68,7 @@ class EnqueueTest(JobRunnerTestCase): """ def test_enqueue(self): - instance = Job() + instance = DataSource() for i in range(1, 3): job = TestJobRunner.enqueue(instance, schedule_at=self.get_schedule_at()) @@ -76,13 +76,13 @@ class EnqueueTest(JobRunnerTestCase): self.assertEqual(TestJobRunner.get_jobs(instance).count(), i) def test_enqueue_once(self): - job = TestJobRunner.enqueue_once(instance=Job(), schedule_at=self.get_schedule_at()) + job = TestJobRunner.enqueue_once(instance=DataSource(), schedule_at=self.get_schedule_at()) self.assertIsInstance(job, Job) self.assertEqual(job.name, TestJobRunner.__name__) def test_enqueue_once_twice_same(self): - instance = Job() + instance = DataSource() schedule_at = self.get_schedule_at() job1 = TestJobRunner.enqueue_once(instance, schedule_at=schedule_at) job2 = TestJobRunner.enqueue_once(instance, schedule_at=schedule_at) @@ -91,7 +91,7 @@ class EnqueueTest(JobRunnerTestCase): self.assertEqual(TestJobRunner.get_jobs(instance).count(), 1) def test_enqueue_once_twice_different_schedule_at(self): - instance = Job() + instance = DataSource() job1 = TestJobRunner.enqueue_once(instance, schedule_at=self.get_schedule_at()) job2 = TestJobRunner.enqueue_once(instance, schedule_at=self.get_schedule_at(2)) @@ -100,7 +100,7 @@ class EnqueueTest(JobRunnerTestCase): self.assertEqual(TestJobRunner.get_jobs(instance).count(), 1) def test_enqueue_once_twice_different_interval(self): - instance = Job() + instance = DataSource() schedule_at = self.get_schedule_at() job1 = TestJobRunner.enqueue_once(instance, schedule_at=schedule_at) job2 = TestJobRunner.enqueue_once(instance, schedule_at=schedule_at, interval=60) @@ -112,7 +112,7 @@ class EnqueueTest(JobRunnerTestCase): self.assertEqual(TestJobRunner.get_jobs(instance).count(), 1) def test_enqueue_once_with_enqueue(self): - instance = Job() + instance = DataSource() job1 = TestJobRunner.enqueue_once(instance, schedule_at=self.get_schedule_at(2)) job2 = TestJobRunner.enqueue(instance, schedule_at=self.get_schedule_at()) @@ -120,7 +120,7 @@ class EnqueueTest(JobRunnerTestCase): self.assertEqual(TestJobRunner.get_jobs(instance).count(), 2) def test_enqueue_once_after_enqueue(self): - instance = Job() + instance = DataSource() job1 = TestJobRunner.enqueue(instance, schedule_at=self.get_schedule_at()) job2 = TestJobRunner.enqueue_once(instance, schedule_at=self.get_schedule_at(2)) diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 19c66cc67..33c8afe42 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -3,7 +3,7 @@ import re from copy import deepcopy from django.contrib import messages -from django.contrib.contenttypes.fields import GenericRel +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRel from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError from django.db import transaction, IntegrityError @@ -583,7 +583,10 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): for name, model_field in model_fields.items(): # Handle nullification if name in form.nullable_fields and name in nullified_fields: - setattr(obj, name, None if model_field.null else '') + if type(model_field) is GenericForeignKey: + setattr(obj, name, None) + else: + setattr(obj, name, None if model_field.null else '') # Normal fields elif name in form.changed_data: setattr(obj, name, form.cleaned_data[name]) @@ -697,7 +700,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): logger.debug("Form validation failed") else: - form = self.form(initial=initial_data) + form = self.form(request.POST, initial=initial_data) restrict_form_fields(form, request.user) # Retrieve objects being edited diff --git a/netbox/netbox/views/generic/feature_views.py b/netbox/netbox/views/generic/feature_views.py index 451c9c01d..49862e83f 100644 --- a/netbox/netbox/views/generic/feature_views.py +++ b/netbox/netbox/views/generic/feature_views.py @@ -4,7 +4,7 @@ from django.contrib import messages from django.db import transaction from django.db.models import Q from django.shortcuts import get_object_or_404, redirect, render -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from django.views.generic import View from core.models import Job, ObjectChange diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 357264522..0686e52b7 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -146,10 +146,12 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin): return render(request, 'htmx/table.html', { 'object': instance, 'table': table, + 'model': self.child_model, }) return render(request, self.get_template_name(), { 'object': instance, + 'model': self.child_model, 'child_model': self.child_model, 'base_template': f'{instance._meta.app_label}/{instance._meta.model_name}.html', 'table': table, diff --git a/netbox/project-static/dist/Inter-Black-7VL4YR6G.woff b/netbox/project-static/dist/Inter-Black-7VL4YR6G.woff new file mode 100644 index 000000000..2f5cb41be Binary files /dev/null and b/netbox/project-static/dist/Inter-Black-7VL4YR6G.woff differ diff --git a/netbox/project-static/dist/Inter-Black-NU3KAVPI.woff2 b/netbox/project-static/dist/Inter-Black-NU3KAVPI.woff2 new file mode 100644 index 000000000..53c0aa239 Binary files /dev/null and b/netbox/project-static/dist/Inter-Black-NU3KAVPI.woff2 differ diff --git a/netbox/project-static/dist/Inter-BlackItalic-4BVAVUVI.woff2 b/netbox/project-static/dist/Inter-BlackItalic-4BVAVUVI.woff2 new file mode 100644 index 000000000..63ec88686 Binary files /dev/null and b/netbox/project-static/dist/Inter-BlackItalic-4BVAVUVI.woff2 differ diff --git a/netbox/project-static/dist/Inter-BlackItalic-HJLUEYVW.woff b/netbox/project-static/dist/Inter-BlackItalic-HJLUEYVW.woff new file mode 100644 index 000000000..a168e5820 Binary files /dev/null and b/netbox/project-static/dist/Inter-BlackItalic-HJLUEYVW.woff differ diff --git a/netbox/project-static/dist/Inter-Bold-6AVOS7AV.woff2 b/netbox/project-static/dist/Inter-Bold-6AVOS7AV.woff2 new file mode 100644 index 000000000..6989c9922 Binary files /dev/null and b/netbox/project-static/dist/Inter-Bold-6AVOS7AV.woff2 differ diff --git a/netbox/project-static/dist/Inter-Bold-RTIBL5DO.woff b/netbox/project-static/dist/Inter-Bold-RTIBL5DO.woff new file mode 100644 index 000000000..2ec7ac3d2 Binary files /dev/null and b/netbox/project-static/dist/Inter-Bold-RTIBL5DO.woff differ diff --git a/netbox/project-static/dist/Inter-BoldItalic-AID2XGS3.woff2 b/netbox/project-static/dist/Inter-BoldItalic-AID2XGS3.woff2 new file mode 100644 index 000000000..18b4c1ce5 Binary files /dev/null and b/netbox/project-static/dist/Inter-BoldItalic-AID2XGS3.woff2 differ diff --git a/netbox/project-static/dist/Inter-BoldItalic-UU7SOVRI.woff b/netbox/project-static/dist/Inter-BoldItalic-UU7SOVRI.woff new file mode 100644 index 000000000..aa35b7974 Binary files /dev/null and b/netbox/project-static/dist/Inter-BoldItalic-UU7SOVRI.woff differ diff --git a/netbox/project-static/dist/Inter-ExtraBold-B5RSZK6R.woff b/netbox/project-static/dist/Inter-ExtraBold-B5RSZK6R.woff new file mode 100644 index 000000000..d79d3d242 Binary files /dev/null and b/netbox/project-static/dist/Inter-ExtraBold-B5RSZK6R.woff differ diff --git a/netbox/project-static/dist/Inter-ExtraBold-VAPCTO5F.woff2 b/netbox/project-static/dist/Inter-ExtraBold-VAPCTO5F.woff2 new file mode 100644 index 000000000..3e74973f2 Binary files /dev/null and b/netbox/project-static/dist/Inter-ExtraBold-VAPCTO5F.woff2 differ diff --git a/netbox/project-static/dist/Inter-ExtraBoldItalic-R7DLWYIK.woff b/netbox/project-static/dist/Inter-ExtraBoldItalic-R7DLWYIK.woff new file mode 100644 index 000000000..06aad19b0 Binary files /dev/null and b/netbox/project-static/dist/Inter-ExtraBoldItalic-R7DLWYIK.woff differ diff --git a/netbox/project-static/dist/Inter-ExtraBoldItalic-W26CJYY7.woff2 b/netbox/project-static/dist/Inter-ExtraBoldItalic-W26CJYY7.woff2 new file mode 100644 index 000000000..77391ea7f Binary files /dev/null and b/netbox/project-static/dist/Inter-ExtraBoldItalic-W26CJYY7.woff2 differ diff --git a/netbox/project-static/dist/Inter-ExtraLight-A7NHN7KT.woff b/netbox/project-static/dist/Inter-ExtraLight-A7NHN7KT.woff new file mode 100644 index 000000000..fee5fca02 Binary files /dev/null and b/netbox/project-static/dist/Inter-ExtraLight-A7NHN7KT.woff differ diff --git a/netbox/project-static/dist/Inter-ExtraLight-DVW3SATI.woff2 b/netbox/project-static/dist/Inter-ExtraLight-DVW3SATI.woff2 new file mode 100644 index 000000000..607250b9a Binary files /dev/null and b/netbox/project-static/dist/Inter-ExtraLight-DVW3SATI.woff2 differ diff --git a/netbox/project-static/dist/Inter-ExtraLightItalic-KXZYPKOE.woff2 b/netbox/project-static/dist/Inter-ExtraLightItalic-KXZYPKOE.woff2 new file mode 100644 index 000000000..858d38a50 Binary files /dev/null and b/netbox/project-static/dist/Inter-ExtraLightItalic-KXZYPKOE.woff2 differ diff --git a/netbox/project-static/dist/Inter-ExtraLightItalic-SKGXKLLA.woff b/netbox/project-static/dist/Inter-ExtraLightItalic-SKGXKLLA.woff new file mode 100644 index 000000000..94e6582b5 Binary files /dev/null and b/netbox/project-static/dist/Inter-ExtraLightItalic-SKGXKLLA.woff differ diff --git a/netbox/project-static/dist/Inter-Italic-GHDVOOKA.woff b/netbox/project-static/dist/Inter-Italic-GHDVOOKA.woff new file mode 100644 index 000000000..4b765bd59 Binary files /dev/null and b/netbox/project-static/dist/Inter-Italic-GHDVOOKA.woff differ diff --git a/netbox/project-static/dist/Inter-Italic-RW2ZALTO.woff2 b/netbox/project-static/dist/Inter-Italic-RW2ZALTO.woff2 new file mode 100644 index 000000000..bd5f255a9 Binary files /dev/null and b/netbox/project-static/dist/Inter-Italic-RW2ZALTO.woff2 differ diff --git a/netbox/project-static/dist/Inter-Light-ARG5Y6MT.woff b/netbox/project-static/dist/Inter-Light-ARG5Y6MT.woff new file mode 100644 index 000000000..7590ff890 Binary files /dev/null and b/netbox/project-static/dist/Inter-Light-ARG5Y6MT.woff differ diff --git a/netbox/project-static/dist/Inter-Light-XBRUIIRZ.woff2 b/netbox/project-static/dist/Inter-Light-XBRUIIRZ.woff2 new file mode 100644 index 000000000..551410ac3 Binary files /dev/null and b/netbox/project-static/dist/Inter-Light-XBRUIIRZ.woff2 differ diff --git a/netbox/project-static/dist/Inter-LightItalic-4OWY6V5R.woff2 b/netbox/project-static/dist/Inter-LightItalic-4OWY6V5R.woff2 new file mode 100644 index 000000000..976d52564 Binary files /dev/null and b/netbox/project-static/dist/Inter-LightItalic-4OWY6V5R.woff2 differ diff --git a/netbox/project-static/dist/Inter-LightItalic-WFS25IQZ.woff b/netbox/project-static/dist/Inter-LightItalic-WFS25IQZ.woff new file mode 100644 index 000000000..8f2a4ca46 Binary files /dev/null and b/netbox/project-static/dist/Inter-LightItalic-WFS25IQZ.woff differ diff --git a/netbox/project-static/dist/Inter-Medium-JV63DMR7.woff2 b/netbox/project-static/dist/Inter-Medium-JV63DMR7.woff2 new file mode 100644 index 000000000..a916b47fc Binary files /dev/null and b/netbox/project-static/dist/Inter-Medium-JV63DMR7.woff2 differ diff --git a/netbox/project-static/dist/Inter-Medium-KH2CGZVE.woff b/netbox/project-static/dist/Inter-Medium-KH2CGZVE.woff new file mode 100644 index 000000000..7d55f34cc Binary files /dev/null and b/netbox/project-static/dist/Inter-Medium-KH2CGZVE.woff differ diff --git a/netbox/project-static/dist/Inter-MediumItalic-P7H6BBYU.woff2 b/netbox/project-static/dist/Inter-MediumItalic-P7H6BBYU.woff2 new file mode 100644 index 000000000..f623924ae Binary files /dev/null and b/netbox/project-static/dist/Inter-MediumItalic-P7H6BBYU.woff2 differ diff --git a/netbox/project-static/dist/Inter-MediumItalic-PO2E4FIG.woff b/netbox/project-static/dist/Inter-MediumItalic-PO2E4FIG.woff new file mode 100644 index 000000000..422ab0576 Binary files /dev/null and b/netbox/project-static/dist/Inter-MediumItalic-PO2E4FIG.woff differ diff --git a/netbox/project-static/dist/Inter-Regular-373XMOLM.woff2 b/netbox/project-static/dist/Inter-Regular-373XMOLM.woff2 new file mode 100644 index 000000000..554aed661 Binary files /dev/null and b/netbox/project-static/dist/Inter-Regular-373XMOLM.woff2 differ diff --git a/netbox/project-static/dist/Inter-Regular-DR3VC6YG.woff b/netbox/project-static/dist/Inter-Regular-DR3VC6YG.woff new file mode 100644 index 000000000..7ff51b7d8 Binary files /dev/null and b/netbox/project-static/dist/Inter-Regular-DR3VC6YG.woff differ diff --git a/netbox/project-static/dist/Inter-SemiBold-TJ6HPMMH.woff b/netbox/project-static/dist/Inter-SemiBold-TJ6HPMMH.woff new file mode 100644 index 000000000..76e507a51 Binary files /dev/null and b/netbox/project-static/dist/Inter-SemiBold-TJ6HPMMH.woff differ diff --git a/netbox/project-static/dist/Inter-SemiBold-UMRILHOG.woff2 b/netbox/project-static/dist/Inter-SemiBold-UMRILHOG.woff2 new file mode 100644 index 000000000..930799899 Binary files /dev/null and b/netbox/project-static/dist/Inter-SemiBold-UMRILHOG.woff2 differ diff --git a/netbox/project-static/dist/Inter-SemiBoldItalic-ABO2DCC7.woff2 b/netbox/project-static/dist/Inter-SemiBoldItalic-ABO2DCC7.woff2 new file mode 100644 index 000000000..f19f5505e Binary files /dev/null and b/netbox/project-static/dist/Inter-SemiBoldItalic-ABO2DCC7.woff2 differ diff --git a/netbox/project-static/dist/Inter-SemiBoldItalic-DM4JJSFU.woff b/netbox/project-static/dist/Inter-SemiBoldItalic-DM4JJSFU.woff new file mode 100644 index 000000000..382181212 Binary files /dev/null and b/netbox/project-static/dist/Inter-SemiBoldItalic-DM4JJSFU.woff differ diff --git a/netbox/project-static/dist/Inter-Thin-MEKOZMGA.woff b/netbox/project-static/dist/Inter-Thin-MEKOZMGA.woff new file mode 100644 index 000000000..6074d22b9 Binary files /dev/null and b/netbox/project-static/dist/Inter-Thin-MEKOZMGA.woff differ diff --git a/netbox/project-static/dist/Inter-Thin-TCINWRKS.woff2 b/netbox/project-static/dist/Inter-Thin-TCINWRKS.woff2 new file mode 100644 index 000000000..07d4fd6f2 Binary files /dev/null and b/netbox/project-static/dist/Inter-Thin-TCINWRKS.woff2 differ diff --git a/netbox/project-static/dist/Inter-ThinItalic-WRULPH3M.woff2 b/netbox/project-static/dist/Inter-ThinItalic-WRULPH3M.woff2 new file mode 100644 index 000000000..a824b2c98 Binary files /dev/null and b/netbox/project-static/dist/Inter-ThinItalic-WRULPH3M.woff2 differ diff --git a/netbox/project-static/dist/Inter-ThinItalic-ZFOL6JFL.woff b/netbox/project-static/dist/Inter-ThinItalic-ZFOL6JFL.woff new file mode 100644 index 000000000..e612ae45e Binary files /dev/null and b/netbox/project-static/dist/Inter-ThinItalic-ZFOL6JFL.woff differ diff --git a/netbox/project-static/dist/Inter-italic.var-7LS4VCXC.woff2 b/netbox/project-static/dist/Inter-italic.var-7LS4VCXC.woff2 new file mode 100644 index 000000000..03875311a Binary files /dev/null and b/netbox/project-static/dist/Inter-italic.var-7LS4VCXC.woff2 differ diff --git a/netbox/project-static/dist/Inter-roman.var-EDENVCUS.woff2 b/netbox/project-static/dist/Inter-roman.var-EDENVCUS.woff2 new file mode 100644 index 000000000..a6efdc486 Binary files /dev/null and b/netbox/project-static/dist/Inter-roman.var-EDENVCUS.woff2 differ diff --git a/netbox/project-static/dist/Inter.var-FTIQFLGO.woff2 b/netbox/project-static/dist/Inter.var-FTIQFLGO.woff2 new file mode 100644 index 000000000..b40083cbb Binary files /dev/null and b/netbox/project-static/dist/Inter.var-FTIQFLGO.woff2 differ diff --git a/netbox/project-static/dist/netbox-external.css b/netbox/project-static/dist/netbox-external.css index 7952f0e1e..72d40bc92 100644 Binary files a/netbox/project-static/dist/netbox-external.css and b/netbox/project-static/dist/netbox-external.css differ diff --git a/netbox/project-static/dist/netbox.css b/netbox/project-static/dist/netbox.css index f1b9e24e2..cd66ffa0f 100644 Binary files a/netbox/project-static/dist/netbox.css and b/netbox/project-static/dist/netbox.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index afdbea0f8..969d5c73a 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 212be3659..0f4ac63d3 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/dist/plus-jakarta-sans-cyrillic-ext-wght-normal-3FNWYSHQ.woff2 b/netbox/project-static/dist/plus-jakarta-sans-cyrillic-ext-wght-normal-3FNWYSHQ.woff2 deleted file mode 100644 index c456409f0..000000000 Binary files a/netbox/project-static/dist/plus-jakarta-sans-cyrillic-ext-wght-normal-3FNWYSHQ.woff2 and /dev/null differ diff --git a/netbox/project-static/dist/plus-jakarta-sans-latin-ext-wght-normal-WWG75Y4U.woff2 b/netbox/project-static/dist/plus-jakarta-sans-latin-ext-wght-normal-WWG75Y4U.woff2 deleted file mode 100644 index bdb7edfd4..000000000 Binary files a/netbox/project-static/dist/plus-jakarta-sans-latin-ext-wght-normal-WWG75Y4U.woff2 and /dev/null differ diff --git a/netbox/project-static/dist/plus-jakarta-sans-latin-wght-normal-S6EFCQOU.woff2 b/netbox/project-static/dist/plus-jakarta-sans-latin-wght-normal-S6EFCQOU.woff2 deleted file mode 100644 index 8c4a84afe..000000000 Binary files a/netbox/project-static/dist/plus-jakarta-sans-latin-wght-normal-S6EFCQOU.woff2 and /dev/null differ diff --git a/netbox/project-static/dist/plus-jakarta-sans-vietnamese-wght-normal-NNXUV3SN.woff2 b/netbox/project-static/dist/plus-jakarta-sans-vietnamese-wght-normal-NNXUV3SN.woff2 deleted file mode 100644 index 57866b15f..000000000 Binary files a/netbox/project-static/dist/plus-jakarta-sans-vietnamese-wght-normal-NNXUV3SN.woff2 and /dev/null differ diff --git a/netbox/project-static/package.json b/netbox/project-static/package.json index 5c16bee60..0750f397b 100644 --- a/netbox/project-static/package.json +++ b/netbox/project-static/package.json @@ -22,7 +22,6 @@ "validate:formatting:scripts": "prettier -c src/**/*.ts" }, "dependencies": { - "@fontsource-variable/plus-jakarta-sans": "^5.1.0", "@mdi/font": "7.4.47", "@tabler/core": "1.0.0-beta21", "bootstrap": "5.3.3", @@ -30,9 +29,10 @@ "flatpickr": "4.6.13", "gridstack": "10.3.1", "htmx.org": "1.9.12", - "query-string": "9.1.0", - "sass": "1.78.0", + "query-string": "9.1.1", + "sass": "1.80.5", "tom-select": "2.3.1", + "typeface-inter": "3.18.1", "typeface-roboto-mono": "1.1.13" }, "devDependencies": { diff --git a/netbox/project-static/src/buttons/floatBulk.ts b/netbox/project-static/src/buttons/floatBulk.ts new file mode 100644 index 000000000..0735690cd --- /dev/null +++ b/netbox/project-static/src/buttons/floatBulk.ts @@ -0,0 +1,42 @@ +import { getElements } from '../util'; + +/** + * Conditionally add and remove a class that will float the button group + * based on whether or not items in the list are checked + */ +function toggleFloat(): void { + const checkedCheckboxes = document.querySelector( + 'input[type="checkbox"][name="pk"]:checked', + ); + const buttonGroup = document.querySelector( + 'div.form.form-horizontal div.btn-list', + ); + if (!buttonGroup) { + return; + } + const isFloating = buttonGroup.classList.contains('btn-float-group-left'); + if (checkedCheckboxes !== null && !isFloating) { + buttonGroup.classList.add('btn-float-group-left'); + } else if (checkedCheckboxes === null && isFloating) { + buttonGroup.classList.remove('btn-float-group-left'); + } +} + +/** + * Initialize floating bulk buttons. + */ +export function initFloatBulk(): void { + for (const element of getElements('input[type="checkbox"][name="pk"]')) { + element.addEventListener('change', () => { + toggleFloat(); + }); + } + // Handle the select-all checkbox + for (const element of getElements( + 'table tr th > input[type="checkbox"].toggle', + )) { + element.addEventListener('change', () => { + toggleFloat(); + }); + } +} diff --git a/netbox/project-static/src/buttons/index.ts b/netbox/project-static/src/buttons/index.ts index 6c1c0db0b..cb520b818 100644 --- a/netbox/project-static/src/buttons/index.ts +++ b/netbox/project-static/src/buttons/index.ts @@ -3,6 +3,7 @@ import { initDepthToggle } from './depthToggle'; import { initMoveButtons } from './moveOptions'; import { initReslug } from './reslug'; import { initSelectAll } from './selectAll'; +import { initFloatBulk } from './floatBulk'; import { initSelectMultiple } from './selectMultiple'; import { initMarkdownPreviews } from './markdownPreview'; import { initSecretToggle } from './secretToggle'; @@ -14,6 +15,7 @@ export function initButtons(): void { initReslug, initSelectAll, initSelectMultiple, + initFloatBulk, initMoveButtons, initMarkdownPreviews, initSecretToggle, diff --git a/netbox/project-static/styles/_variables.scss b/netbox/project-static/styles/_variables.scss index 749f0ba57..33b144532 100644 --- a/netbox/project-static/styles/_variables.scss +++ b/netbox/project-static/styles/_variables.scss @@ -1,10 +1,9 @@ // Global variables // Set base fonts -$font-family-sans-serif: 'Plus Jakarta Sans Variable', system-ui, sans-serif; +$font-family-sans-serif: 'Inter', system-ui, sans-serif; // See https://github.com/tabler/tabler/issues/1812 $font-family-monospace: 'Roboto Mono'; -$font-weight-base: 500; // Set the navigation sidebar width $sidebar-width: 18rem; diff --git a/netbox/project-static/styles/custom/_markdown.scss b/netbox/project-static/styles/custom/_markdown.scss index 32ef7a09c..75ada3bc6 100644 --- a/netbox/project-static/styles/custom/_markdown.scss +++ b/netbox/project-static/styles/custom/_markdown.scss @@ -28,16 +28,19 @@ } -// Remove the bottom margin of

elements inside a table cell -td > .rendered-markdown { - max-height: 200px; - overflow-y: scroll; - +// Remove the bottom margin of the last

elements in markdown +.rendered-markdown { p:last-of-type { margin-bottom: 0; } } +// fix layout of rendered markdown inside a table cell +td > .rendered-markdown { + max-height: 200px; + overflow-y: scroll; +} + // Markdown preview .markdown-widget { .preview { diff --git a/netbox/project-static/styles/custom/_misc.scss b/netbox/project-static/styles/custom/_misc.scss index 08125e8d3..4ef2f1337 100644 --- a/netbox/project-static/styles/custom/_misc.scss +++ b/netbox/project-static/styles/custom/_misc.scss @@ -33,3 +33,33 @@ span.color-label { .netbox-edition { letter-spacing: .15rem; } + +// A floating div for form buttons +.btn-float-group { + position: sticky; + bottom: 10px; + z-index: 2; +} + +.btn-float-group-left { + @extend .btn-float-group; + float: left; +} + +.btn-float-group-right { + @extend .btn-float-group; + float: right; +} + +// Override a transparent background +.btn-float { + --tblr-btn-bg: var(--#{$prefix}bg-surface-tertiary) !important; +} + +.logo { + height: 80px; +} + +.sso-icon { + height: 24px; +} diff --git a/netbox/project-static/styles/external.scss b/netbox/project-static/styles/external.scss index 1e09d1803..b3c83aa87 100644 --- a/netbox/project-static/styles/external.scss +++ b/netbox/project-static/styles/external.scss @@ -4,5 +4,5 @@ @import 'gridstack/dist/gridstack.min.css'; // Fonts +@import url("../node_modules/typeface-inter/inter.css"); @import url("../node_modules/typeface-roboto-mono/index.css"); -@import url("../node_modules/@fontsource-variable/plus-jakarta-sans/index.css"); diff --git a/netbox/project-static/styles/overrides/_tabler.scss b/netbox/project-static/styles/overrides/_tabler.scss index 814a6d82b..6f7c7cc8c 100644 --- a/netbox/project-static/styles/overrides/_tabler.scss +++ b/netbox/project-static/styles/overrides/_tabler.scss @@ -128,6 +128,14 @@ body[data-bs-theme=dark] { .footer .text-primary { color: white !important; } + .toast { + color: var(--#{$prefix}body-color); + } + .table-primary { + --tblr-table-bg: rgba(var(--tblr-secondary-rgb), 0.48); + --tblr-table-hover-bg: inherit; + --tblr-table-hover-color: inherit; + } } // Do not apply padding to elements inside a

diff --git a/netbox/project-static/yarn.lock b/netbox/project-static/yarn.lock
index ca9f5ff96..44c9d994d 100644
--- a/netbox/project-static/yarn.lock
+++ b/netbox/project-static/yarn.lock
@@ -200,11 +200,6 @@
   resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.2.tgz#d8bae93ac8b815b2bd7a98078cf91e2724ef11e5"
   integrity sha512-J4yDIIthosAsRZ5CPYP/jQvUAQtlZTTD/4suA08/FEnlxqW3sKS9iAhgsa9VYLZ6vDHn/ixJgIqRQPotoBjxIw==
 
-"@fontsource-variable/plus-jakarta-sans@^5.1.0":
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/@fontsource-variable/plus-jakarta-sans/-/plus-jakarta-sans-5.1.0.tgz#9dd5aa72277c43d408f6e34cd658d823fbef1fe1"
-  integrity sha512-K7o2GO7/quVFFyfjTqYZu0ng4KJGf60KSAGbvUWwKuH+/Giyl6Qe/EqnGLcjrl9AmXZzTvtRB1xpkASn8FNFoQ==
-
 "@graphiql/plugin-explorer@3.2.2":
   version "3.2.2"
   resolved "https://registry.yarnpkg.com/@graphiql/plugin-explorer/-/plugin-explorer-3.2.2.tgz#973d6015b6db15041902e95c3e4b746473313eb6"
@@ -370,6 +365,89 @@
   resolved "https://registry.yarnpkg.com/@orchidjs/unicode-variants/-/unicode-variants-1.0.4.tgz#6d2f812e3b19545bba2d81caffff1204de9a6a58"
   integrity sha512-NvVBRnZNE+dugiXERFsET1JlKZfM5lJDEpSMilKW4bToYJ7pxf0Zne78xyXB2ny2c2aHfJ6WLnz1AaTNHAmQeQ==
 
+"@parcel/watcher-android-arm64@2.4.1":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.4.1.tgz#c2c19a3c442313ff007d2d7a9c2c1dd3e1c9ca84"
+  integrity sha512-LOi/WTbbh3aTn2RYddrO8pnapixAziFl6SMxHM69r3tvdSm94JtCenaKgk1GRg5FJ5wpMCpHeW+7yqPlvZv7kg==
+
+"@parcel/watcher-darwin-arm64@2.4.1":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.4.1.tgz#c817c7a3b4f3a79c1535bfe54a1c2818d9ffdc34"
+  integrity sha512-ln41eihm5YXIY043vBrrHfn94SIBlqOWmoROhsMVTSXGh0QahKGy77tfEywQ7v3NywyxBBkGIfrWRHm0hsKtzA==
+
+"@parcel/watcher-darwin-x64@2.4.1":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.4.1.tgz#1a3f69d9323eae4f1c61a5f480a59c478d2cb020"
+  integrity sha512-yrw81BRLjjtHyDu7J61oPuSoeYWR3lDElcPGJyOvIXmor6DEo7/G2u1o7I38cwlcoBHQFULqF6nesIX3tsEXMg==
+
+"@parcel/watcher-freebsd-x64@2.4.1":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.4.1.tgz#0d67fef1609f90ba6a8a662bc76a55fc93706fc8"
+  integrity sha512-TJa3Pex/gX3CWIx/Co8k+ykNdDCLx+TuZj3f3h7eOjgpdKM+Mnix37RYsYU4LHhiYJz3DK5nFCCra81p6g050w==
+
+"@parcel/watcher-linux-arm-glibc@2.4.1":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.4.1.tgz#ce5b340da5829b8e546bd00f752ae5292e1c702d"
+  integrity sha512-4rVYDlsMEYfa537BRXxJ5UF4ddNwnr2/1O4MHM5PjI9cvV2qymvhwZSFgXqbS8YoTk5i/JR0L0JDs69BUn45YA==
+
+"@parcel/watcher-linux-arm64-glibc@2.4.1":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.4.1.tgz#6d7c00dde6d40608f9554e73998db11b2b1ff7c7"
+  integrity sha512-BJ7mH985OADVLpbrzCLgrJ3TOpiZggE9FMblfO65PlOCdG++xJpKUJ0Aol74ZUIYfb8WsRlUdgrZxKkz3zXWYA==
+
+"@parcel/watcher-linux-arm64-musl@2.4.1":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.4.1.tgz#bd39bc71015f08a4a31a47cd89c236b9d6a7f635"
+  integrity sha512-p4Xb7JGq3MLgAfYhslU2SjoV9G0kI0Xry0kuxeG/41UfpjHGOhv7UoUDAz/jb1u2elbhazy4rRBL8PegPJFBhA==
+
+"@parcel/watcher-linux-x64-glibc@2.4.1":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.4.1.tgz#0ce29966b082fb6cdd3de44f2f74057eef2c9e39"
+  integrity sha512-s9O3fByZ/2pyYDPoLM6zt92yu6P4E39a03zvO0qCHOTjxmt3GHRMLuRZEWhWLASTMSrrnVNWdVI/+pUElJBBBg==
+
+"@parcel/watcher-linux-x64-musl@2.4.1":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.4.1.tgz#d2ebbf60e407170bb647cd6e447f4f2bab19ad16"
+  integrity sha512-L2nZTYR1myLNST0O632g0Dx9LyMNHrn6TOt76sYxWLdff3cB22/GZX2UPtJnaqQPdCRoszoY5rcOj4oMTtp5fQ==
+
+"@parcel/watcher-win32-arm64@2.4.1":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.4.1.tgz#eb4deef37e80f0b5e2f215dd6d7a6d40a85f8adc"
+  integrity sha512-Uq2BPp5GWhrq/lcuItCHoqxjULU1QYEcyjSO5jqqOK8RNFDBQnenMMx4gAl3v8GiWa59E9+uDM7yZ6LxwUIfRg==
+
+"@parcel/watcher-win32-ia32@2.4.1":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.4.1.tgz#94fbd4b497be39fd5c8c71ba05436927842c9df7"
+  integrity sha512-maNRit5QQV2kgHFSYwftmPBxiuK5u4DXjbXx7q6eKjq5dsLXZ4FJiVvlcw35QXzk0KrUecJmuVFbj4uV9oYrcw==
+
+"@parcel/watcher-win32-x64@2.4.1":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.4.1.tgz#4bf920912f67cae5f2d264f58df81abfea68dadf"
+  integrity sha512-+DvS92F9ezicfswqrvIRM2njcYJbd5mb9CUgtrHCHmvn7pPPa+nMDRu1o1bYYz/l5IB2NVGNJWiH7h1E58IF2A==
+
+"@parcel/watcher@^2.4.1":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.4.1.tgz#a50275151a1bb110879c6123589dba90c19f1bf8"
+  integrity sha512-HNjmfLQEVRZmHRET336f20H/8kOozUGwk7yajvsonjNxbj2wBTK1WsQuHkD5yYh9RxFGL2EyDHryOihOwUoKDA==
+  dependencies:
+    detect-libc "^1.0.3"
+    is-glob "^4.0.3"
+    micromatch "^4.0.5"
+    node-addon-api "^7.0.0"
+  optionalDependencies:
+    "@parcel/watcher-android-arm64" "2.4.1"
+    "@parcel/watcher-darwin-arm64" "2.4.1"
+    "@parcel/watcher-darwin-x64" "2.4.1"
+    "@parcel/watcher-freebsd-x64" "2.4.1"
+    "@parcel/watcher-linux-arm-glibc" "2.4.1"
+    "@parcel/watcher-linux-arm64-glibc" "2.4.1"
+    "@parcel/watcher-linux-arm64-musl" "2.4.1"
+    "@parcel/watcher-linux-x64-glibc" "2.4.1"
+    "@parcel/watcher-linux-x64-musl" "2.4.1"
+    "@parcel/watcher-win32-arm64" "2.4.1"
+    "@parcel/watcher-win32-ia32" "2.4.1"
+    "@parcel/watcher-win32-x64" "2.4.1"
+
 "@pkgr/core@^0.1.0":
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.1.1.tgz#1ec17e2edbec25c8306d424ecfbf13c7de1aaa31"
@@ -1043,6 +1121,13 @@ chalk@^4.0.0:
   optionalDependencies:
     fsevents "~2.3.2"
 
+chokidar@^4.0.0:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.1.tgz#4a6dff66798fb0f72a94f616abbd7e1a19f31d41"
+  integrity sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==
+  dependencies:
+    readdirp "^4.0.1"
+
 client-only@^0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1"
@@ -1194,6 +1279,11 @@ delegate@^3.1.2:
   resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166"
   integrity sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==
 
+detect-libc@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
+  integrity sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==
+
 detect-node-es@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493"
@@ -2203,7 +2293,7 @@ meros@^1.1.4:
   resolved "https://registry.yarnpkg.com/meros/-/meros-1.3.0.tgz#c617d2092739d55286bf618129280f362e6242f2"
   integrity sha512-2BNGOimxEz5hmjUG2FwoxCt5HN7BXdaWyFqEwxPTrJzVdABtrL4TiHTcsWSFAxPQ/tOnEaQEJh3qWq71QRMY+w==
 
-micromatch@^4.0.4:
+micromatch@^4.0.4, micromatch@^4.0.5:
   version "4.0.8"
   resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
   integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
@@ -2245,6 +2335,11 @@ natural-compare@^1.4.0:
   resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
   integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
 
+node-addon-api@^7.0.0:
+  version "7.1.1"
+  resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558"
+  integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==
+
 normalize-path@^3.0.0, normalize-path@~3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
@@ -2415,10 +2510,10 @@ punycode@^2.1.0:
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
   integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
 
-query-string@9.1.0:
-  version "9.1.0"
-  resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.1.0.tgz#5f12a4653a4ba56021e113b5cf58e56581823e7a"
-  integrity sha512-t6dqMECpCkqfyv2FfwVS1xcB6lgXW/0XZSaKdsCNGYkqMO76AFiJEg4vINzoDKcZa6MS7JX+OHIjwh06K5vczw==
+query-string@9.1.1:
+  version "9.1.1"
+  resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.1.1.tgz#dbfebb4196aeb2919915f2b2b81b91b965cf03a0"
+  integrity sha512-MWkCOVIcJP9QSKU52Ngow6bsAWAPlPK2MludXvcrS2bGZSl+T1qX9MZvRIkqUIkGLJquMJHWfsT6eRqUpp4aWg==
   dependencies:
     decode-uri-component "^0.4.1"
     filter-obj "^5.1.0"
@@ -2472,6 +2567,11 @@ react@18.3.1:
   dependencies:
     loose-envify "^1.1.0"
 
+readdirp@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.0.1.tgz#b2fe35f8dca63183cd3b86883ecc8f720ea96ae6"
+  integrity sha512-GkMg9uOTpIWWKbSsgwb5fA4EavTR+SG/PMPoAY8hkhHfEEY0/vqljY+XHqtDf2cr2IJtoNRDbrrEpZUiZCkYRw==
+
 readdirp@~3.6.0:
   version "3.6.0"
   resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
@@ -2556,12 +2656,13 @@ safe-regex-test@^1.0.3:
     es-errors "^1.3.0"
     is-regex "^1.1.4"
 
-sass@1.78.0:
-  version "1.78.0"
-  resolved "https://registry.yarnpkg.com/sass/-/sass-1.78.0.tgz#cef369b2f9dc21ea1d2cf22c979f52365da60841"
-  integrity sha512-AaIqGSrjo5lA2Yg7RvFZrlXDBCp3nV4XP73GrLGvdRWWwk+8H3l0SDvq/5bA4eF+0RFPLuWUk3E+P1U/YqnpsQ==
+sass@1.80.5:
+  version "1.80.5"
+  resolved "https://registry.yarnpkg.com/sass/-/sass-1.80.5.tgz#0ba965223d44df22497f2966b498cf5c453fae8f"
+  integrity sha512-TQd2aoQl/+zsxRMEDSxVdpPIqeq9UFc6pr7PzkugiTx3VYCFPUaa3P4RrBQsqok4PO200Vkz0vXQBNlg7W907g==
   dependencies:
-    chokidar ">=3.0.0 <4.0.0"
+    "@parcel/watcher" "^2.4.1"
+    chokidar "^4.0.0"
     immutable "^4.0.0"
     source-map-js ">=0.6.2 <2.0.0"
 
@@ -2852,6 +2953,11 @@ typed-array-length@^1.0.6:
     is-typed-array "^1.1.13"
     possible-typed-array-names "^1.0.0"
 
+typeface-inter@3.18.1:
+  version "3.18.1"
+  resolved "https://registry.yarnpkg.com/typeface-inter/-/typeface-inter-3.18.1.tgz#24cccdf29923f318589783997be20a662cd3ab9c"
+  integrity sha512-c+TBanYFCvmg3j5vPk+zxK4ocMZbPxMEmjnwG7rPQoV87xvQ6b07VbAOC0Va0XBbbZCGw6cWNeFuLeg1YQru3Q==
+
 typeface-roboto-mono@1.1.13:
   version "1.1.13"
   resolved "https://registry.yarnpkg.com/typeface-roboto-mono/-/typeface-roboto-mono-1.1.13.tgz#2af8662db8f9119c00efd55d6ed8877d2a69ec94"
diff --git a/netbox/release.yaml b/netbox/release.yaml
index dd0cb6c71..b4811804f 100644
--- a/netbox/release.yaml
+++ b/netbox/release.yaml
@@ -1,3 +1,3 @@
-version: "4.1.1"
+version: "4.1.6"
 edition: "Community"
-published: "2024-09-12"
+published: "2024-10-31"
diff --git a/netbox/templates/core/plugin.html b/netbox/templates/core/plugin.html
index 34eaf81ee..b833db037 100644
--- a/netbox/templates/core/plugin.html
+++ b/netbox/templates/core/plugin.html
@@ -2,6 +2,7 @@
 {% load helpers %}
 {% load form_helpers %}
 {% load i18n %}
+{% load render_table from django_tables2 %}
 
 {% block title %}{{ plugin.title_long }}{% endblock %}
 
@@ -93,8 +94,8 @@
       

{% trans "Version History" %}

-
- {% include 'htmx/table.html' %} +
+ {% render_table table 'inc/table.html' %}
diff --git a/netbox/templates/dcim/component_list.html b/netbox/templates/dcim/component_list.html index 6351643e8..6f91aff3e 100644 --- a/netbox/templates/dcim/component_list.html +++ b/netbox/templates/dcim/component_list.html @@ -10,7 +10,7 @@ {% endif %} {% if 'bulk_rename' in actions %} {% with bulk_rename_view=model|validated_viewname:"bulk_rename" %} - {% endwith %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index b74d4b5f6..fb11e9a5c 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -325,6 +325,7 @@ {% if object.total_weight %} {{ object.total_weight|floatformat }} {% trans "Kilograms" %} + ({{ object.total_weight|kg_to_pounds|floatformat }} {% trans "Pounds" %}) {% else %} {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/device_list.html b/netbox/templates/dcim/device_list.html index 41ef8fc73..493b652f5 100644 --- a/netbox/templates/dcim/device_list.html +++ b/netbox/templates/dcim/device_list.html @@ -78,7 +78,7 @@ {% if 'bulk_edit' in actions %}
{% bulk_edit_button model query_params=request.GET %} -
diff --git a/netbox/templates/dcim/devicetype/component_templates.html b/netbox/templates/dcim/devicetype/component_templates.html index df9789c00..291c7c988 100644 --- a/netbox/templates/dcim/devicetype/component_templates.html +++ b/netbox/templates/dcim/devicetype/component_templates.html @@ -18,21 +18,8 @@ {% endif %} {% endwith %} {% endblock bulk_edit_controls %} - -{% block bulk_extra_controls %} - {{ block.super }} - {% if request.user|can_add:child_model %} - - {% endif %} -{% endblock bulk_extra_controls %} - diff --git a/netbox/templates/dcim/inc/devicetype_breadcrumbs.html b/netbox/templates/dcim/inc/devicetype_breadcrumbs.html new file mode 100644 index 000000000..02f326ddc --- /dev/null +++ b/netbox/templates/dcim/inc/devicetype_breadcrumbs.html @@ -0,0 +1,2 @@ + + diff --git a/netbox/templates/dcim/inc/moduletype_buttons.html b/netbox/templates/dcim/inc/moduletype_buttons.html new file mode 100644 index 000000000..9ec34f412 --- /dev/null +++ b/netbox/templates/dcim/inc/moduletype_buttons.html @@ -0,0 +1,38 @@ +{% load buttons %} +{% load helpers %} +{% load i18n %} + + +{% if perms.dcim.change_devicetype %} + +{% endif %} diff --git a/netbox/templates/dcim/moduletype.html b/netbox/templates/dcim/moduletype.html index 110db8e20..f57c501cf 100644 --- a/netbox/templates/dcim/moduletype.html +++ b/netbox/templates/dcim/moduletype.html @@ -1,9 +1,20 @@ -{% extends 'dcim/moduletype/base.html' %} +{% extends 'generic/object.html' %} {% load buttons %} {% load helpers %} {% load plugins %} {% load i18n %} +{% block title %}{{ object.manufacturer }} {{ object.model }}{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + {% include 'dcim/inc/devicetype_breadcrumbs.html' %} +{% endblock %} + +{% block extra_controls %} + {% include 'dcim/inc/moduletype_buttons.html' %} +{% endblock %} + {% block content %}
diff --git a/netbox/templates/dcim/moduletype/base.html b/netbox/templates/dcim/moduletype/base.html deleted file mode 100644 index b7d9026d5..000000000 --- a/netbox/templates/dcim/moduletype/base.html +++ /dev/null @@ -1,48 +0,0 @@ -{% extends 'generic/object.html' %} -{% load buttons %} -{% load helpers %} -{% load plugins %} -{% load i18n %} - -{% block title %}{{ object.manufacturer }} {{ object.model }}{% endblock %} - -{% block breadcrumbs %} - {{ block.super }} - -{% endblock %} - -{% block extra_controls %} - {% if perms.dcim.change_devicetype %} - - {% endif %} -{% endblock %} diff --git a/netbox/templates/dcim/moduletype/component_templates.html b/netbox/templates/dcim/moduletype/component_templates.html index 9c4d59cba..8f4c24478 100644 --- a/netbox/templates/dcim/moduletype/component_templates.html +++ b/netbox/templates/dcim/moduletype/component_templates.html @@ -1,44 +1,37 @@ -{% extends 'dcim/moduletype/base.html' %} +{% extends 'generic/object_children.html' %} {% load render_table from django_tables2 %} {% load helpers %} {% load i18n %} -{% block content %} - {% if perms.dcim.change_moduletype %} -
- {% csrf_token %} -
-
- {% include 'htmx/table.html' %} -
- -
-
- {% else %} -
-

{{ title }}

-
- {% include 'htmx/table.html' %} -
-
- {% endif %} -{% endblock content %} +{% block title %}{{ object.manufacturer }} {{ object.model }}{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + {% include 'dcim/inc/devicetype_breadcrumbs.html' %} +{% endblock %} + +{% block extra_controls %} + {% include 'dcim/inc/moduletype_buttons.html' %} +{% endblock %} + +{% block bulk_edit_controls %} + {% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %} + {% if 'bulk_edit' in actions and bulk_edit_view %} + + {% endif %} + {% endwith %} + {% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %} + {% if 'bulk_rename' in actions and bulk_rename_view %} + + {% endif %} + {% endwith %} +{% endblock bulk_edit_controls %} + diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index ad035dd6b..eec4d63a5 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -103,8 +103,12 @@ {% trans "Total Weight" %} - {{ object.total_weight|floatformat }} {% trans "Kilograms" %} - ({{ object.total_weight|kg_to_pounds|floatformat }} {% trans "Pounds" %}) + {% if object.total_weight %} + {{ object.total_weight|floatformat }} {% trans "Kilograms" %} + ({{ object.total_weight|kg_to_pounds|floatformat }} {% trans "Pounds" %}) + {% else %} + {{ ''|placeholder }} + {% endif %} diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html index 4699e2c09..cbffbf8de 100644 --- a/netbox/templates/extras/script_list.html +++ b/netbox/templates/extras/script_list.html @@ -37,101 +37,104 @@ {% endif %}
- {% if module.scripts %} - - - - - - - - - - - - {% for script in module.scripts.all %} - {% with last_job=script.get_latest_jobs|get_key:script.name %} - - +
{% trans "Name" %}{% trans "Description" %}{% trans "Last Run" %}{% trans "Status" %}
- {% if script.is_executable %} - {{ script.python_class.name }} + {% with scripts=module.scripts.all %} + {% if scripts %} + + + + + + + + + + + + {% for script in scripts %} + {% with last_job=script.get_latest_jobs|first %} + + + + {% if last_job %} + + {% else %} - {{ script.python_class.name }} - - - + + {% endif %} - - + + {% if last_job %} - - - {% else %} - - + {% for test_name, data in last_job.data.tests.items %} + + + + + {% endfor %} + {% elif not last_job.data.log %} + {# legacy #} + {% for method, stats in last_job.data.items %} + + + + + {% endfor %} {% endif %} - - - {% if last_job %} - {% for test_name, data in last_job.data.tests.items %} - - - - - {% endfor %} - {% elif not last_job.data.log %} - {# legacy #} - {% for method, stats in last_job.data.items %} - - - - - {% endfor %} - {% endif %} - {% endwith %} - {% endfor %} - -
{% trans "Name" %}{% trans "Description" %}{% trans "Last Run" %}{% trans "Status" %}
+ {% if script.is_executable %} + {{ script.python_class.name }} + {% else %} + {{ script.python_class.name }} + + + + {% endif %} + {{ script.python_class.Meta.description|markdown|placeholder }} + {{ last_job.created|isodatetime }} + + {% badge last_job.get_status_display last_job.get_status_color %} + {% trans "Never" %}{{ ''|placeholder }}{{ script.python_class.Meta.description|markdown|placeholder }} + {% if request.user|can_run:script and script.is_executable %} +
+
+ {% csrf_token %} + +
+
+ {% endif %} +
- {{ last_job.created|isodatetime }} - - {% badge last_job.get_status_display last_job.get_status_color %} - {% trans "Never" %}{{ ''|placeholder }}
+ {{ test_name }} + + {{ data.success }} + {{ data.info }} + {{ data.warning }} + {{ data.failure }} +
+ {{ method }} + + {{ stats.success }} + {{ stats.info }} + {{ stats.warning }} + {{ stats.failure }} +
- {% if request.user|can_run:script and script.is_executable %} -
-
- {% csrf_token %} - -
-
- {% endif %} -
- {{ test_name }} - - {{ data.success }} - {{ data.info }} - {{ data.warning }} - {{ data.failure }} -
- {{ method }} - - {{ stats.success }} - {{ stats.info }} - {{ stats.warning }} - {{ stats.failure }} -
- {% else %} -
-
+ {% else %} +
+
-
- {% endif %} + {% endif %} + {% endwith %}
{% empty %}