Просмотр исходного кода

Merge branch 'develop' into feature

Jeremy Stretch 1 год назад
Родитель
Сommit
256b5dc676
100 измененных файлов с 506 добавлено и 247 удалено
  1. 16 1
      .github/ISSUE_TEMPLATE/01-feature_request.yaml
  2. 15 1
      .github/ISSUE_TEMPLATE/02-bug_report.yaml
  3. 2 2
      .github/workflows/ci.yml
  4. 5 6
      README.md
  5. 3 0
      contrib/generated_schema.json
  6. 4 4
      docs/administration/authentication/microsoft-entra-id.md
  7. 2 2
      docs/development/getting-started.md
  8. 16 16
      docs/development/style-guide.md
  9. 1 1
      docs/features/authentication-permissions.md
  10. 3 0
      docs/features/synchronized-data.md
  11. BIN
      docs/media/misc/netbox_cloud.png
  12. BIN
      docs/media/misc/netbox_logo.png
  13. 2 2
      docs/models/ipam/asn.md
  14. 1 0
      docs/models/vpn/l2vpn.md
  15. 46 1
      docs/release-notes/version-4.1.md
  16. 1 1
      mkdocs.yml
  17. 1 1
      netbox/circuits/api/nested_serializers.py
  18. 1 1
      netbox/circuits/apps.py
  19. 0 1
      netbox/circuits/forms/bulk_import.py
  20. 1 2
      netbox/circuits/graphql/filters.py
  21. 1 1
      netbox/circuits/tests/test_views.py
  22. 1 1
      netbox/core/api/nested_serializers.py
  23. 1 3
      netbox/core/api/schema.py
  24. 0 2
      netbox/core/api/serializers_/jobs.py
  25. 2 2
      netbox/core/apps.py
  26. 18 4
      netbox/core/data_backends.py
  27. 1 1
      netbox/core/graphql/mixins.py
  28. 2 2
      netbox/core/management/commands/syncdatasource.py
  29. 2 2
      netbox/core/models/data.py
  30. 6 7
      netbox/core/models/jobs.py
  31. 1 1
      netbox/dcim/api/nested_serializers.py
  32. 0 2
      netbox/dcim/api/serializers_/manufacturers.py
  33. 0 2
      netbox/dcim/api/serializers_/platforms.py
  34. 0 2
      netbox/dcim/api/serializers_/power.py
  35. 0 2
      netbox/dcim/api/serializers_/roles.py
  36. 1 1
      netbox/dcim/apps.py
  37. 8 0
      netbox/dcim/choices.py
  38. 1 1
      netbox/dcim/filtersets.py
  39. 2 2
      netbox/dcim/forms/bulk_import.py
  40. 1 1
      netbox/dcim/forms/connections.py
  41. 4 4
      netbox/dcim/forms/model_forms.py
  42. 2 2
      netbox/dcim/forms/object_create.py
  43. 20 21
      netbox/dcim/graphql/mixins.py
  44. 1 1
      netbox/dcim/management/commands/trace_paths.py
  45. 26 0
      netbox/dcim/migrations/0191_module_bay_rebuild.py
  46. 0 1
      netbox/dcim/models/device_component_templates.py
  47. 1 2
      netbox/dcim/models/device_components.py
  48. 1 1
      netbox/dcim/search.py
  49. 3 0
      netbox/dcim/svg/cables.py
  50. 3 0
      netbox/dcim/tables/devices.py
  51. 1 2
      netbox/dcim/tables/devicetypes.py
  52. 4 0
      netbox/dcim/tables/template_code.py
  53. 2 2
      netbox/dcim/tests/test_api.py
  54. 0 7
      netbox/dcim/tests/test_filtersets.py
  55. 26 28
      netbox/dcim/tests/test_models.py
  56. 1 1
      netbox/dcim/tests/test_views.py
  57. 0 2
      netbox/dcim/utils.py
  58. 1 1
      netbox/extras/api/nested_serializers.py
  59. 0 2
      netbox/extras/api/serializers_/configtemplates.py
  60. 0 2
      netbox/extras/api/serializers_/customlinks.py
  61. 0 2
      netbox/extras/api/serializers_/exporttemplates.py
  62. 0 2
      netbox/extras/api/serializers_/savedfilters.py
  63. 0 2
      netbox/extras/api/serializers_/tags.py
  64. 1 2
      netbox/extras/api/views.py
  65. 1 1
      netbox/extras/apps.py
  66. 0 1
      netbox/extras/dashboard/widgets.py
  67. 1 1
      netbox/extras/graphql/types.py
  68. 1 1
      netbox/extras/jobs.py
  69. 2 1
      netbox/extras/lookups.py
  70. 2 2
      netbox/extras/management/commands/housekeeping.py
  71. 2 2
      netbox/extras/management/commands/reindex.py
  72. 1 1
      netbox/extras/management/commands/runscript.py
  73. 34 2
      netbox/extras/models/customfields.py
  74. 1 1
      netbox/extras/scripts.py
  75. 12 5
      netbox/extras/tests/test_api.py
  76. 68 0
      netbox/extras/tests/test_customfields.py
  77. 1 1
      netbox/extras/tests/test_customvalidators.py
  78. 4 4
      netbox/extras/tests/test_models.py
  79. 1 1
      netbox/extras/tests/test_views.py
  80. 0 1
      netbox/extras/validators.py
  81. 1 2
      netbox/extras/views.py
  82. 1 1
      netbox/ipam/api/nested_serializers.py
  83. 0 2
      netbox/ipam/api/serializers_/roles.py
  84. 0 2
      netbox/ipam/api/serializers_/services.py
  85. 0 2
      netbox/ipam/api/serializers_/vrfs.py
  86. 3 3
      netbox/ipam/api/views.py
  87. 1 1
      netbox/ipam/apps.py
  88. 26 0
      netbox/ipam/filtersets.py
  89. 2 3
      netbox/ipam/graphql/mixins.py
  90. 1 1
      netbox/ipam/tables/ip.py
  91. 0 2
      netbox/ipam/tests/test_api.py
  92. 40 0
      netbox/ipam/tests/test_filtersets.py
  93. 4 4
      netbox/ipam/tests/test_views.py
  94. 0 2
      netbox/netbox/api/serializers/nested.py
  95. 4 4
      netbox/netbox/authentication/__init__.py
  96. 1 1
      netbox/netbox/config/__init__.py
  97. 1 1
      netbox/netbox/data_backends.py
  98. 5 3
      netbox/netbox/filtersets.py
  99. 4 4
      netbox/netbox/graphql/filter_mixins.py
  100. 16 16
      netbox/netbox/navigation/menu.py

+ 16 - 1
.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.3
     validations:
       required: true
   - type: dropdown
@@ -24,6 +24,21 @@ 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
+        - This is a very minor change
+        - N/A
+      default: 3
     validations:
       required: true
   - type: textarea

+ 15 - 1
.github/ISSUE_TEMPLATE/02-bug_report.yaml

@@ -22,11 +22,25 @@ 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
+        - This is preventing me from using NetBox
+        - N/A
+      default: 3
+    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.3
     validations:
       required: true
   - type: dropdown

+ 2 - 2
.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

+ 5 - 6
README.md

@@ -7,7 +7,11 @@
   <a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
   <a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-15-blue" alt="Languages supported" /></a>
   <a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" /></a>
-  <p></p>
+  <p>
+    <strong><a href="https://github.com/netbox-community/netbox/">NetBox Community</a></strong> |
+    <strong><a href="https://netboxlabs.com/netbox-cloud/">NetBox Cloud</a></strong> |
+    <strong><a href="https://netboxlabs.com/netbox-enterprise/">NetBox Enterprise</a></strong>
+  </p>
 </div>
 
 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!
 
-<p align="center">
-  <a href="https://netboxlabs.com/netbox-cloud/"><img src="docs/media/misc/netbox_cloud.png" alt="NetBox Cloud" /></a><br />
-  Looking for a managed solution? Check out <strong><a href="https://netboxlabs.com/netbox-cloud/">NetBox Cloud</a></strong> or <strong><a href="https://netboxlabs.com/netbox-enterprise/">NetBox Enterprise</a></strong>!
-</p>
-
 ## Get Involved
 
 * Follow [@NetBoxOfficial](https://twitter.com/NetBoxOfficial) on Twitter!

+ 3 - 0
contrib/generated_schema.json

@@ -290,6 +290,7 @@
                         "molex-micro-fit-2x2",
                         "molex-micro-fit-2x4",
                         "dc-terminal",
+                        "eaton-c39",
                         "hdot-cx",
                         "saf-d-grid",
                         "neutrik-powercon-20a",
@@ -330,6 +331,7 @@
                         "5gbase-t",
                         "10gbase-t",
                         "10gbase-cx4",
+                        "100base-x-sfp",
                         "1000base-x-gbic",
                         "1000base-x-sfp",
                         "10gbase-x-sfpp",
@@ -381,6 +383,7 @@
                         "ieee802.11ay",
                         "ieee802.11be",
                         "ieee802.15.1",
+                        "ieee802.15.4",
                         "other-wireless",
                         "gsm",
                         "cdma",

+ 4 - 4
docs/administration/authentication/microsoft-azure-ad.md → 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)
 
@@ -16,7 +16,7 @@ Under the Azure Active Directory dashboard, navigate to **Add > App registration
 
 Enter a name for the registration (e.g. "NetBox") and ensure that the "single tenant" option is selected.
 
-Under "Redirect URI", select "Web" for the platform and enter the path to your NetBox installation, ending with `/oauth/complete/azuread-oauth2/`. Note that this URI **must** begin with `https://` unless you are referencing localhost (for development purposes).
+Under "Redirect URI", select "Web" for the platform and enter the path to your NetBox installation, ending with `/oauth/complete/entraid-oauth2/`. Note that this URI **must** begin with `https://` unless you are referencing localhost (for development purposes).
 
 ![App registration parameters](../../media/authentication/azure_ad_app_registration.png)
 

+ 2 - 2
docs/development/getting-started.md

@@ -70,10 +70,10 @@ NetBox ships with a [git pre-commit hook](https://githooks.com/) script that aut
 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:
+For the pre-commit hooks to work, you will also need to install the [ruff](https://docs.astral.sh/ruff/) linter:
 
 ```no-highlight
-python -m pip install pycodestyle
+python -m pip install ruff
 ```
 ...and set up the yarn packages as shown in the [Web UI Development Guide](web-ui.md)
 

+ 16 - 16
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/
+```
 
-Wildcard imports (for example, `from .constants import *`) are acceptable under any of the following conditions:
+#### Linter Exceptions
 
-* The library being import contains only constant declarations (e.g. `constants.py`)
-* The library being imported explicitly defines `__all__`
+The following rules are ignored when linting.
 
-#### Maximum Line Length (E501)
+##### [E501](https://docs.astral.sh/ruff/rules/line-too-long/): Line too long
 
-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).
+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).
 
-#### Line Breaks Following Binary Operators (W504)
+##### [F403](https://docs.astral.sh/ruff/rules/undefined-local-with-import-star/): Undefined local with import star
 
-Line breaks are permitted following binary operators.
+Wildcard imports (for example, `from .constants import *`) are acceptable under any of the following conditions:
 
-### Enforcing Code Style
+* The library being import contains only constant declarations (e.g. `constants.py`)
+* The library being imported explicitly defines `__all__`
 
-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:
+##### [F405](https://docs.astral.sh/ruff/rules/undefined-local-with-import-star-usage/): Undefined local with import star usage
 
-```
-pycodestyle --ignore=W504,E501 netbox/
-```
+The justification for ignoring this rule is the same as F403 above.
 
 ### Introducing New Dependencies
 

+ 1 - 1
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

+ 3 - 0
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:

BIN
docs/media/misc/netbox_cloud.png


BIN
docs/media/misc/netbox_logo.png


+ 2 - 2
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
 

+ 1 - 0
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.

+ 46 - 1
docs/release-notes/version-4.1.md

@@ -1,11 +1,48 @@
 # NetBox v4.1
 
-## v4.1.2 (FUTURE)
+## v4.1.4 (FUTURE)
+
+### 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
+* [#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
+* [#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
+* [#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
+* [#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
+
+---
+
+## 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
@@ -15,14 +52,22 @@
 * [#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
 
 ---
 

+ 1 - 1
mkdocs.yml

@@ -156,7 +156,7 @@ nav:
     - Administration:
         - Authentication:
             - Overview: 'administration/authentication/overview.md'
-            - Microsoft Azure AD: 'administration/authentication/microsoft-azure-ad.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'

+ 1 - 1
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
 )
 

+ 1 - 1
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())

+ 0 - 1
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 *

+ 1 - 2
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__ = (

+ 1 - 1
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}",

+ 1 - 1
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
 )
 

+ 1 - 3
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

+ 0 - 2
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

+ 2 - 2
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())

+ 18 - 4
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(

+ 1 - 1
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,

+ 2 - 2
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.")

+ 2 - 2
netbox/core/models/data.py

@@ -122,7 +122,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):
@@ -198,7 +198,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

+ 6 - 7
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):
@@ -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))
 

+ 1 - 1
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
 )
 

+ 0 - 2
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

+ 0 - 2
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

+ 0 - 2
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

+ 0 - 2
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

+ 1 - 1
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

+ 8 - 0
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'
@@ -1036,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)'),

+ 1 - 1
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():

+ 2 - 2
netbox/dcim/forms/bulk_import.py

@@ -368,13 +368,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',

+ 1 - 1
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
 
 

+ 4 - 4
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(

+ 2 - 2
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',
             }
         )
     )

+ 20 - 21
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")]]

+ 1 - 1
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:

+ 26 - 0
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
+        ),
+    ]

+ 0 - 1
netbox/dcim/models/device_component_templates.py

@@ -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:

+ 1 - 2
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',

+ 1 - 1
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

+ 3 - 0
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}'

+ 3 - 0
netbox/dcim/tables/devices.py

@@ -589,6 +589,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(

+ 1 - 2
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

+ 4 - 0
netbox/dcim/tables/template_code.py

@@ -56,9 +56,13 @@ INTERFACE_FHRPGROUPS = """
 
 INTERFACE_TAGGED_VLANS = """
 {% if record.mode == 'tagged' %}
+  {% if value.count > 3 %}
+    <a href="{% url 'ipam:vlan_list' %}?{{ record|meta:"model_name" }}_id={{ record.pk }}">{{ value.count }} VLANs</a>
+  {% else %}
     {% for vlan in value.all %}
         <a href="{{ vlan.get_absolute_url }}">{{ vlan }}</a><br />
     {% endfor %}
+  {% endif %}
 {% elif record.mode == 'tagged-all' %}
   All
 {% endif %}

+ 2 - 2
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)
 

+ 0 - 7
netbox/dcim/tests/test_filtersets.py

@@ -4842,13 +4842,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]}

+ 26 - 28
netbox/dcim/tests/test_models.py

@@ -663,10 +663,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
@@ -682,8 +680,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()
@@ -709,7 +705,7 @@ class ModuleBayTestCase(TestCase):
             location=location,
             rack=rack
         )
-        cp = device.consoleports.first()
+        device.consoleports.first()
 
     def test_nested_module_token(self):
         pass
@@ -734,39 +730,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_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_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_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):
         """

+ 1 - 1
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",

+ 0 - 2
netbox/dcim/utils.py

@@ -1,5 +1,3 @@
-import itertools
-
 from django.contrib.contenttypes.models import ContentType
 from django.db import transaction
 

+ 1 - 1
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
 )
 

+ 0 - 2
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

+ 0 - 2
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

+ 0 - 2
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

+ 0 - 2
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

+ 0 - 2
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

+ 1 - 2
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

+ 1 - 1
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())

+ 0 - 1
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

+ 1 - 1
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(

+ 1 - 1
netbox/extras/jobs.py

@@ -48,7 +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")
+                    logger.warning("Script failed")
                     raise
 
         except Exception as e:

+ 2 - 1
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
 
 

+ 2 - 2
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)

+ 2 - 2
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)

+ 1 - 1
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)

+ 34 - 2
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

+ 1 - 1
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:

+ 12 - 5
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):

+ 68 - 0
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
 

+ 1 - 1
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')

+ 4 - 4
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',

+ 1 - 1
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',
         )
 

+ 0 - 1
netbox/extras/validators.py

@@ -1,4 +1,3 @@
-import inspect
 import operator
 
 from django.core import validators

+ 1 - 2
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

+ 1 - 1
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
 )
 

+ 0 - 2
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

+ 0 - 2
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

+ 0 - 2
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

+ 3 - 3
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
                 )
 

+ 1 - 1
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())

+ 26 - 0
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(

+ 2 - 3
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

+ 1 - 1
netbox/ipam/tables/ip.py

@@ -51,7 +51,7 @@ IPADDRESS_LINK = """
 {% if record.pk %}
     <a href="{{ record.get_absolute_url }}" id="ipaddress_{{ record.pk }}">{{ record.address }}</a>
 {% elif perms.ipam.add_ipaddress %}
-    <a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.tenant %}&tenant={{ object.tenant.pk }}{% endif %}" class="btn btn-sm btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available</a>
+    <a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.tenant %}&tenant={{ object.tenant.pk }}{% endif %}&return_url={% url 'ipam:prefix_ipaddresses' pk=object.pk %}" class="btn btn-sm btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available</a>
 {% else %}
     {% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available
 {% endif %}

+ 0 - 2
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')
 

+ 40 - 0
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()

+ 4 - 4
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",

+ 0 - 2
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

+ 4 - 4
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'),
+    'entraid-oauth2': ('Microsoft Entra ID', 'microsoft'),
+    'entraid-b2c-oauth2': ('Microsoft Entra ID', 'microsoft'),
+    'entraid-tenant-oauth2': ('Microsoft Entra ID', 'microsoft'),
+    'entraid-v2-tenant-oauth2': ('Microsoft Entra ID', 'microsoft'),
     'bitbucket': ('BitBucket', 'bitbucket'),
     'bitbucket-oauth2': ('BitBucket', 'bitbucket'),
     'digitalocean': ('DigitalOcean', 'digital-ocean'),

+ 1 - 1
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()

+ 1 - 1
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()

+ 5 - 3
netbox/netbox/filtersets.py

@@ -180,9 +180,11 @@ 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
-                    for field_to_remove in ('choices', 'null_value'):
-                        existing_filter_extra.pop(field_to_remove, None)
-                    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,

+ 4 - 4
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):

+ 16 - 16
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'])
                 ),
             ),

Некоторые файлы не были показаны из-за большого количества измененных файлов