Merge branch 'develop' into feature

This commit is contained in:
Jeremy Stretch 2024-10-11 11:29:36 -04:00
commit 256b5dc676
232 changed files with 3954 additions and 3332 deletions

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v4.1.1 placeholder: v4.1.3
validations: validations:
required: true required: true
- type: dropdown - type: dropdown
@ -24,6 +24,21 @@ body:
- Data model extension - Data model extension
- New functionality - New functionality
- Change to existing 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: validations:
required: true required: true
- type: textarea - type: textarea

View File

@ -22,11 +22,25 @@ body:
- Self-hosted - Self-hosted
validations: validations:
required: true 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 - type: input
attributes: attributes:
label: NetBox Version label: NetBox Version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v4.1.1 placeholder: v4.1.3
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -73,7 +73,7 @@ jobs:
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r requirements.txt pip install -r requirements.txt
pip install pycodestyle coverage tblib pip install ruff coverage tblib
- name: Build documentation - name: Build documentation
run: mkdocs build run: mkdocs build
@ -85,7 +85,7 @@ jobs:
run: python netbox/manage.py makemigrations --check run: python netbox/manage.py makemigrations --check
- name: Check PEP8 compliance - 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 - name: Check UI ESLint, TypeScript, and Prettier Compliance
run: yarn --cwd netbox/project-static validate run: yarn --cwd netbox/project-static validate

View File

@ -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://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://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> <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> </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. 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. * 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! * 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 ## Get Involved
* Follow [@NetBoxOfficial](https://twitter.com/NetBoxOfficial) on Twitter! * Follow [@NetBoxOfficial](https://twitter.com/NetBoxOfficial) on Twitter!

View File

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

View File

@ -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) ### 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. 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) ![App registration parameters](../../media/authentication/azure_ad_app_registration.png)

View File

@ -70,10 +70,10 @@ NetBox ships with a [git pre-commit hook](https://githooks.com/) script that aut
cd .git/hooks/ cd .git/hooks/
ln -s ../../scripts/git-hooks/pre-commit 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 ```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) ...and set up the yarn packages as shown in the [Web UI Development Guide](web-ui.md)

View File

@ -1,6 +1,6 @@
# Style Guide # 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 ## 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`. * Nested API serializers generate minimal representations of an object. These are stored separately from the primary serializers to avoid circular dependencies. Always import nested serializers from other apps directly. For example, from within the DCIM app you would write `from ipam.api.nested_serializers import NestedIPAddressSerializer`.
### PEP 8 Exceptions ### Linting
NetBox ignores certain PEP8 assertions. These are listed below. The [ruff](https://docs.astral.sh/ruff/) linter is used to enforce code style. A [pre-commit hook](./getting-started.md#3-enable-pre-commit-hooks) which runs this automatically is included with NetBox. To invoke `ruff` manually, run:
#### Wildcard Imports ```
ruff check netbox/
```
#### Linter Exceptions
The following rules are ignored when linting.
##### [E501](https://docs.astral.sh/ruff/rules/line-too-long/): Line too long
NetBox does not enforce a hard restriction on line length, although a maximum length of 120 characters is strongly encouraged for Python code where possible. The maximum length does not apply to HTML templates or to automatically generated code (e.g. database migrations).
##### [F403](https://docs.astral.sh/ruff/rules/undefined-local-with-import-star/): Undefined local with import star
Wildcard imports (for example, `from .constants import *`) are acceptable under any of the following conditions: Wildcard imports (for example, `from .constants import *`) are acceptable under any of the following conditions:
* The library being import contains only constant declarations (e.g. `constants.py`) * The library being import contains only constant declarations (e.g. `constants.py`)
* The library being imported explicitly defines `__all__` * The library being imported explicitly defines `__all__`
#### Maximum Line Length (E501) ##### [F405](https://docs.astral.sh/ruff/rules/undefined-local-with-import-star-usage/): Undefined local with import star usage
NetBox does not restrict lines to a maximum length of 79 characters. We use a maximum line length of 120 characters, however this is not enforced by CI. The maximum length does not apply to HTML templates or to automatically generated code (e.g. database migrations). The justification for ignoring this rule is the same as F403 above.
#### Line Breaks Following Binary Operators (W504)
Line breaks are permitted following binary operators.
### Enforcing Code Style
The [`pycodestyle`](https://pypi.org/project/pycodestyle/) utility (formerly `pep8`) is used by the CI process to enforce code style. A [pre-commit hook](./getting-started.md#3-enable-pre-commit-hooks) which runs this automatically is included with NetBox. To invoke `pycodestyle` manually, run:
```
pycodestyle --ignore=W504,E501 netbox/
```
### Introducing New Dependencies ### Introducing New Dependencies

View File

@ -41,7 +41,7 @@ NetBox integrates with the open source [python-social-auth](https://github.com/p
* Google * Google
* Hashicorp Vault * Hashicorp Vault
* Keycloak * Keycloak
* Microsoft Azure AD * Microsoft Entra ID
* Microsoft Graph * Microsoft Graph
* Okta * Okta
* OIDC * OIDC

View File

@ -13,6 +13,9 @@ To enable remote data synchronization, the NetBox administrator first designates
!!! info !!! 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. 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. 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: The following NetBox models can be associated with replicated data files:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -1,6 +1,6 @@
# ASNs # 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). 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 ### AS Number
The 32- or 64-bit AS number. The 16- or 32-bit AS number.
### RIR ### RIR

View File

@ -28,6 +28,7 @@ The technology employed in forming and operating the L2VPN. Choices include:
* VXLAN-EVPN * VXLAN-EVPN
* MPLS-EVPN * MPLS-EVPN
* PBB-EVPN * PBB-EVPN
* EVPN-VPWS
!!! note !!! note
Designating the type as VPWS, EPL, EP-LAN, EP-TREE will limit the L2VPN instance to two terminations. Designating the type as VPWS, EPL, EP-LAN, EP-TREE will limit the L2VPN instance to two terminations.

View File

@ -1,11 +1,48 @@
# NetBox v4.1 # 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 ### Enhancements
* [#14201](https://github.com/netbox-community/netbox/issues/14201) - Enable global search for AS numbers using "AS" prefix * [#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) * [#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 * [#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` * [#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 * [#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 * [#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 * [#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 * [#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 * [#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 * [#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 * [#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 * [#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 * [#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 * [#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 * [#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 * [#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
--- ---

View File

@ -156,7 +156,7 @@ nav:
- Administration: - Administration:
- Authentication: - Authentication:
- Overview: 'administration/authentication/overview.md' - 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' - Okta: 'administration/authentication/okta.md'
- Permissions: 'administration/permissions.md' - Permissions: 'administration/permissions.md'
- Error Reporting: 'administration/error-reporting.md' - Error Reporting: 'administration/error-reporting.md'

View File

@ -18,7 +18,7 @@ __all__ = [
# TODO: Remove in v4.2 # TODO: Remove in v4.2
warnings.warn( 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 DeprecationWarning
) )

View File

@ -7,7 +7,7 @@ class CircuitsConfig(AppConfig):
def ready(self): def ready(self):
from netbox.models.features import register_models from netbox.models.features import register_models
from . import signals, search from . import signals, search # noqa: F401
# Register models # Register models
register_models(*self.get_models()) register_models(*self.get_models())

View File

@ -1,5 +1,4 @@
from django import forms from django import forms
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from circuits.choices import * from circuits.choices import *

View File

@ -1,7 +1,6 @@
import strawberry
import strawberry_django import strawberry_django
from circuits import filtersets, models
from circuits import filtersets, models
from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
__all__ = ( __all__ = (

View File

@ -171,7 +171,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
) )
cls.csv_update_data = ( 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[0].pk},Circuit 7,New description7,{CircuitStatusChoices.STATUS_DECOMMISSIONED}",
f"{circuits[1].pk},Circuit 8,New description8,{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}", f"{circuits[2].pk},Circuit 9,New description9,{CircuitStatusChoices.STATUS_DECOMMISSIONED}",

View File

@ -16,7 +16,7 @@ __all__ = (
# TODO: Remove in v4.2 # TODO: Remove in v4.2
warnings.warn( 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 DeprecationWarning
) )

View File

@ -8,10 +8,8 @@ from drf_spectacular.plumbing import (
build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc, build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc,
) )
from drf_spectacular.types import OpenApiTypes 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 from netbox.api.serializers import WritableNestedSerializer
# see netbox.api.routers.NetBoxRouter # see netbox.api.routers.NetBoxRouter

View File

@ -1,5 +1,3 @@
from rest_framework import serializers
from core.choices import * from core.choices import *
from core.models import Job from core.models import Job
from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.fields import ChoiceField, ContentTypeField

View File

@ -16,9 +16,9 @@ class CoreConfig(AppConfig):
name = "core" name = "core"
def ready(self): def ready(self):
from core.api import schema # noqa from core.api import schema # noqa: F401
from netbox.models.features import register_models 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
register_models(*self.get_models()) register_models(*self.get_models())

View File

@ -8,10 +8,13 @@ from urllib.parse import urlparse
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from netbox.data_backends import DataBackend from netbox.data_backends import DataBackend
from netbox.utils import register_data_backend 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 from .exceptions import SyncError
__all__ = ( __all__ = (
@ -31,7 +34,7 @@ class LocalBackend(DataBackend):
@contextmanager @contextmanager
def fetch(self): 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 local_path = urlparse(self.url).path # Strip file:// scheme
yield local_path yield local_path
@ -67,11 +70,18 @@ class GitBackend(DataBackend):
# Initialize backend config # Initialize backend config
config = ConfigDict() config = ConfigDict()
self.use_socks = False
# Apply HTTP proxy (if configured) # Apply HTTP proxy (if configured)
if settings.HTTP_PROXIES and self.url_scheme in ('http', 'https'): if settings.HTTP_PROXIES:
if proxy := settings.HTTP_PROXIES.get(self.url_scheme): if proxy := settings.HTTP_PROXIES.get(self.url_scheme, None):
config.set("http", "proxy", proxy) 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 return config
@ -87,6 +97,10 @@ class GitBackend(DataBackend):
"errstream": porcelain.NoneStream(), "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.url_scheme in ('http', 'https'):
if self.params.get('username'): if self.params.get('username'):
clone_args.update( clone_args.update(

View File

@ -15,7 +15,7 @@ __all__ = (
class ChangelogMixin: class ChangelogMixin:
@strawberry_django.field @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) content_type = ContentType.objects.get_for_model(self)
object_changes = ObjectChange.objects.filter( object_changes = ObjectChange.objects.filter(
changed_object_type=content_type, changed_object_type=content_type,

View File

@ -26,7 +26,7 @@ class Command(BaseCommand):
if invalid_names := set(options['name']) - found_names: if invalid_names := set(options['name']) - found_names:
raise CommandError(f"Invalid data source names: {', '.join(invalid_names)}") raise CommandError(f"Invalid data source names: {', '.join(invalid_names)}")
else: 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: if len(options['name']) > 1:
self.stdout.write(f"Syncing {len(datasources)} data sources.") self.stdout.write(f"Syncing {len(datasources)} data sources.")
@ -43,4 +43,4 @@ class Command(BaseCommand):
raise e raise e
if len(options['name']) > 1: if len(options['name']) > 1:
self.stdout.write(f"Finished.") self.stdout.write("Finished.")

View File

@ -122,7 +122,7 @@ class DataSource(JobsMixin, PrimaryModel):
# Ensure URL scheme matches selected type # Ensure URL scheme matches selected type
if self.backend_class.is_local and self.url_scheme not in ('file', ''): if self.backend_class.is_local and self.url_scheme not in ('file', ''):
raise ValidationError({ 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): def to_objectchange(self, action):
@ -198,7 +198,7 @@ class DataSource(JobsMixin, PrimaryModel):
logger.debug(f"Updated {updated_count} files") logger.debug(f"Updated {updated_count} files")
# Bulk delete deleted 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") logger.debug(f"Deleted {deleted_count} files")
# Walk the local replication to find new files # Walk the local replication to find new files

View File

@ -13,8 +13,6 @@ from django.utils.translation import gettext as _
from core.choices import JobStatusChoices from core.choices import JobStatusChoices
from core.models import ObjectType from core.models import ObjectType
from core.signals import job_end, job_start 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.querysets import RestrictedQuerySet
from utilities.rqworker import get_queue_for_model from utilities.rqworker import get_queue_for_model
@ -118,10 +116,11 @@ class Job(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
# TODO: Employ dynamic registration # TODO: Employ dynamic registration
if self.object_type.model == 'reportmodule': if self.object_type:
return reverse(f'extras:report_result', kwargs={'job_pk': self.pk}) if self.object_type.model == 'reportmodule':
if self.object_type.model == 'scriptmodule': return reverse('extras:report_result', kwargs={'job_pk': self.pk})
return reverse(f'extras:script_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]) return reverse('core:job', args=[self.pk])
def get_status_color(self): def get_status_color(self):
@ -154,7 +153,7 @@ class Job(models.Model):
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
super().delete(*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) queue = django_rq.get_queue(rq_queue_name)
job = queue.fetch_job(str(self.job_id)) job = queue.fetch_job(str(self.job_id))

View File

@ -56,7 +56,7 @@ __all__ = [
# TODO: Remove in v4.2 # TODO: Remove in v4.2
warnings.warn( 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 DeprecationWarning
) )

View File

@ -1,5 +1,3 @@
from rest_framework import serializers
from dcim.models import Manufacturer from dcim.models import Manufacturer
from netbox.api.fields import RelatedObjectCountField from netbox.api.fields import RelatedObjectCountField
from netbox.api.serializers import NetBoxModelSerializer from netbox.api.serializers import NetBoxModelSerializer

View File

@ -1,5 +1,3 @@
from rest_framework import serializers
from dcim.models import Platform from dcim.models import Platform
from extras.api.serializers_.configtemplates import ConfigTemplateSerializer from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
from netbox.api.fields import RelatedObjectCountField from netbox.api.fields import RelatedObjectCountField

View File

@ -1,5 +1,3 @@
from rest_framework import serializers
from dcim.choices import * from dcim.choices import *
from dcim.models import PowerFeed, PowerPanel from dcim.models import PowerFeed, PowerPanel
from netbox.api.fields import ChoiceField, RelatedObjectCountField from netbox.api.fields import ChoiceField, RelatedObjectCountField

View File

@ -1,5 +1,3 @@
from rest_framework import serializers
from dcim.models import DeviceRole, InventoryItemRole from dcim.models import DeviceRole, InventoryItemRole
from extras.api.serializers_.configtemplates import ConfigTemplateSerializer from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
from netbox.api.fields import RelatedObjectCountField from netbox.api.fields import RelatedObjectCountField

View File

@ -10,7 +10,7 @@ class DCIMConfig(AppConfig):
def ready(self): def ready(self):
from netbox.models.features import register_models from netbox.models.features import register_models
from utilities.counters import connect_counters from utilities.counters import connect_counters
from . import signals, search from . import signals, search # noqa: F401
from .models import CableTermination, Device, DeviceType, VirtualChassis from .models import CableTermination, Device, DeviceType, VirtualChassis
# Register models # Register models

View File

@ -197,6 +197,9 @@ class DeviceAirflowChoices(ChoiceSet):
AIRFLOW_LEFT_TO_RIGHT = 'left-to-right' AIRFLOW_LEFT_TO_RIGHT = 'left-to-right'
AIRFLOW_RIGHT_TO_LEFT = 'right-to-left' AIRFLOW_RIGHT_TO_LEFT = 'right-to-left'
AIRFLOW_SIDE_TO_REAR = 'side-to-rear' 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_PASSIVE = 'passive'
AIRFLOW_MIXED = 'mixed' AIRFLOW_MIXED = 'mixed'
@ -206,6 +209,9 @@ class DeviceAirflowChoices(ChoiceSet):
(AIRFLOW_LEFT_TO_RIGHT, _('Left to right')), (AIRFLOW_LEFT_TO_RIGHT, _('Left to right')),
(AIRFLOW_RIGHT_TO_LEFT, _('Right to left')), (AIRFLOW_RIGHT_TO_LEFT, _('Right to left')),
(AIRFLOW_SIDE_TO_REAR, _('Side to rear')), (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_PASSIVE, _('Passive')),
(AIRFLOW_MIXED, _('Mixed')), (AIRFLOW_MIXED, _('Mixed')),
) )
@ -863,6 +869,7 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_100ME_LFX = '100base-lfx' TYPE_100ME_LFX = '100base-lfx'
TYPE_100ME_FIXED = '100base-tx' TYPE_100ME_FIXED = '100base-tx'
TYPE_100ME_T1 = '100base-t1' TYPE_100ME_T1 = '100base-t1'
TYPE_100ME_SFP = '100base-x-sfp'
TYPE_1GE_FIXED = '1000base-t' TYPE_1GE_FIXED = '1000base-t'
TYPE_1GE_TX_FIXED = '1000base-tx' TYPE_1GE_TX_FIXED = '1000base-tx'
TYPE_1GE_GBIC = '1000base-x-gbic' TYPE_1GE_GBIC = '1000base-x-gbic'
@ -1036,6 +1043,7 @@ class InterfaceTypeChoices(ChoiceSet):
( (
_('Ethernet (modular)'), _('Ethernet (modular)'),
( (
(TYPE_100ME_SFP, 'SFP (100ME)'),
(TYPE_1GE_GBIC, 'GBIC (1GE)'), (TYPE_1GE_GBIC, 'GBIC (1GE)'),
(TYPE_1GE_SFP, 'SFP (1GE)'), (TYPE_1GE_SFP, 'SFP (1GE)'),
(TYPE_10GE_SFP_PLUS, 'SFP+ (10GE)'), (TYPE_10GE_SFP_PLUS, 'SFP+ (10GE)'),

View File

@ -271,7 +271,7 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM
class Meta: class Meta:
model = Location model = Location
fields = ('id', 'name', 'slug', 'status', 'facility', 'description') fields = ('id', 'name', 'slug', 'facility', 'description')
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

@ -368,13 +368,13 @@ class ManufacturerImportForm(NetBoxModelImportForm):
class DeviceTypeImportForm(NetBoxModelImportForm): class DeviceTypeImportForm(NetBoxModelImportForm):
manufacturer = forms.ModelChoiceField( manufacturer = CSVModelChoiceField(
label=_('Manufacturer'), label=_('Manufacturer'),
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
to_field_name='name', to_field_name='name',
help_text=_('The manufacturer which produces this device type') help_text=_('The manufacturer which produces this device type')
) )
default_platform = forms.ModelChoiceField( default_platform = CSVModelChoiceField(
label=_('Default platform'), label=_('Default platform'),
queryset=Platform.objects.all(), queryset=Platform.objects.all(),
to_field_name='name', to_field_name='name',

View File

@ -4,7 +4,7 @@ from django.utils.translation import gettext_lazy as _
from circuits.models import Circuit, CircuitTermination from circuits.models import Circuit, CircuitTermination
from dcim.models import * from dcim.models import *
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.fields import DynamicModelMultipleChoiceField
from .model_forms import CableForm from .model_forms import CableForm

View File

@ -954,7 +954,7 @@ class PowerOutletTemplateForm(ModularComponentTemplateForm):
queryset=PowerPortTemplate.objects.all(), queryset=PowerPortTemplate.objects.all(),
required=False, required=False,
query_params={ query_params={
'devicetype_id': '$device_type', 'device_type_id': '$device_type',
} }
) )
@ -1001,8 +1001,8 @@ class FrontPortTemplateForm(ModularComponentTemplateForm):
queryset=RearPortTemplate.objects.all(), queryset=RearPortTemplate.objects.all(),
required=False, required=False,
query_params={ query_params={
'devicetype_id': '$device_type', 'device_type_id': '$device_type',
'moduletype_id': '$module_type', 'module_type_id': '$module_type',
} }
) )
@ -1063,7 +1063,7 @@ class InventoryItemTemplateForm(ComponentTemplateForm):
queryset=InventoryItemTemplate.objects.all(), queryset=InventoryItemTemplate.objects.all(),
required=False, required=False,
query_params={ query_params={
'devicetype_id': '$device_type' 'device_type_id': '$device_type'
} }
) )
role = DynamicModelChoiceField( role = DynamicModelChoiceField(

View File

@ -261,8 +261,8 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
# TODO: Clean up the application of HTMXSelect attributes # TODO: Clean up the application of HTMXSelect attributes
attrs={ attrs={
'hx-get': '.', 'hx-get': '.',
'hx-include': f'#form_fields', 'hx-include': '#form_fields',
'hx-target': f'#form_fields', 'hx-target': '#form_fields',
} }
) )
) )

View File

@ -1,7 +1,6 @@
from typing import Annotated, List, Union from typing import Annotated, List, Union
import strawberry import strawberry
import strawberry_django
__all__ = ( __all__ = (
'CabledObjectMixin', 'CabledObjectMixin',
@ -11,18 +10,18 @@ __all__ = (
@strawberry.type @strawberry.type
class CabledObjectMixin: 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[ link_peers: List[Annotated[Union[
Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')], Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')], # noqa: F821
Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')], Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')], Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')], Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')], Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
], strawberry.union("LinkPeerType")]] ], strawberry.union("LinkPeerType")]]
@ -30,14 +29,14 @@ class CabledObjectMixin:
class PathEndpointMixin: class PathEndpointMixin:
connected_endpoints: List[Annotated[Union[ connected_endpoints: List[Annotated[Union[
Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')], Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')], # noqa: F821
Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')], Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')], Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')], Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["ProviderNetworkType", strawberry.lazy('circuits.graphql.types')], Annotated["ProviderNetworkType", strawberry.lazy('circuits.graphql.types')], # noqa: F821
Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')], Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
], strawberry.union("ConnectedEndpointType")]] ], strawberry.union("ConnectedEndpointType")]]

View File

@ -60,7 +60,7 @@ class Command(BaseCommand):
self.stdout.write((self.style.SUCCESS(f' Deleted {deleted_count} paths'))) self.stdout.write((self.style.SUCCESS(f' Deleted {deleted_count} paths')))
# Reinitialize the model's PK sequence # 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]) sequence_sql = connection.ops.sequence_reset_sql(no_style(), [CablePath])
with connection.cursor() as cursor: with connection.cursor() as cursor:
for sql in sequence_sql: for sql in sequence_sql:

View File

@ -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
),
]

View File

@ -160,7 +160,6 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
def _get_module_tree(self, module): def _get_module_tree(self, module):
modules = [] modules = []
all_module_bays = module.device.modulebays.all().select_related('module')
while module: while module:
modules.append(module) modules.append(module)
if module.module_bay: if module.module_bay:

View File

@ -4,7 +4,7 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelatio
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models 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.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import MPTTModel, TreeForeignKey
@ -22,7 +22,6 @@ from utilities.tracking import TrackingModelMixin
from wireless.choices import * from wireless.choices import *
from wireless.utils import get_channel_attr from wireless.utils import get_channel_attr
__all__ = ( __all__ = (
'BaseInterface', 'BaseInterface',
'CabledObjectModel', 'CabledObjectModel',

View File

@ -250,7 +250,7 @@ class RackTypeIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('type', 'description') display_attrs = ('model', 'description')
@register_search @register_search

View File

@ -162,6 +162,9 @@ class CableTraceSVG:
location_label += f' / {instance.location}' location_label += f' / {instance.location}'
if instance.rack: if instance.rack:
location_label += f' / {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) labels.append(location_label)
elif instance._meta.model_name == 'circuit': elif instance._meta.model_name == 'circuit':
labels[0] = f'Circuit {instance}' labels[0] = f'Circuit {instance}'

View File

@ -589,6 +589,9 @@ class BaseInterfaceTable(NetBoxTable):
def value_ip_addresses(self, value): def value_ip_addresses(self, value):
return ",".join([str(obj.address) for obj in value.all()]) 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): class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpointTable):
device = tables.Column( device = tables.Column(

View File

@ -1,6 +1,5 @@
from django.utils.translation import gettext_lazy as _
import django_tables2 as tables 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 dcim import models
from netbox.tables import NetBoxTable, columns from netbox.tables import NetBoxTable, columns

View File

@ -56,9 +56,13 @@ INTERFACE_FHRPGROUPS = """
INTERFACE_TAGGED_VLANS = """ INTERFACE_TAGGED_VLANS = """
{% if record.mode == 'tagged' %} {% 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 %} {% for vlan in value.all %}
<a href="{{ vlan.get_absolute_url }}">{{ vlan }}</a><br /> <a href="{{ vlan.get_absolute_url }}">{{ vlan }}</a><br />
{% endfor %} {% endfor %}
{% endif %}
{% elif record.mode == 'tagged-all' %} {% elif record.mode == 'tagged-all' %}
All All
{% endif %} {% endif %}

View File

@ -2135,12 +2135,12 @@ class ConnectedDeviceTest(APITestCase):
def test_get_connected_device(self): def test_get_connected_device(self):
url = reverse('dcim-api:connected-device-list') 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) response = self.client.get(url + url_params, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK) self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['name'], 'TestDevice2') 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) response = self.client.get(url + url_params, **self.header)
self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND) self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)

View File

@ -4842,13 +4842,6 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'device_role': [role[0].slug, role[1].slug]} params = {'device_role': [role[0].slug, role[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) 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): def test_device(self):
devices = Device.objects.all()[:2] devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]} params = {'device_id': [devices[0].pk, devices[1].pk]}

View File

@ -663,10 +663,8 @@ class ModuleBayTestCase(TestCase):
def test_module_bay_recursion(self): def test_module_bay_recursion(self):
module_bay_1 = ModuleBay.objects.get(name='Module Bay 1') 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_bay_3 = ModuleBay.objects.get(name='Module Bay 3')
module_1 = Module.objects.get(module_bay=module_bay_1) 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) module_3 = Module.objects.get(module_bay=module_bay_3)
# Confirm error if ModuleBay recurses # Confirm error if ModuleBay recurses
@ -682,8 +680,6 @@ class ModuleBayTestCase(TestCase):
module_1.save() module_1.save()
def test_single_module_token(self): def test_single_module_token(self):
module_bays = ModuleBay.objects.all()
modules = Module.objects.all()
device_type = DeviceType.objects.first() device_type = DeviceType.objects.first()
device_role = DeviceRole.objects.first() device_role = DeviceRole.objects.first()
site = Site.objects.first() site = Site.objects.first()
@ -709,7 +705,7 @@ class ModuleBayTestCase(TestCase):
location=location, location=location,
rack=rack rack=rack
) )
cp = device.consoleports.first() device.consoleports.first()
def test_nested_module_token(self): def test_nested_module_token(self):
pass pass
@ -734,39 +730,41 @@ class CableTestCase(TestCase):
device2 = Device.objects.create( device2 = Device.objects.create(
device_type=devicetype, role=role, name='TestDevice2', site=site device_type=devicetype, role=role, name='TestDevice2', site=site
) )
interface1 = Interface.objects.create(device=device1, name='eth0') interfaces = (
interface2 = Interface.objects.create(device=device2, name='eth0') Interface(device=device1, name='eth0'),
interface3 = Interface.objects.create(device=device2, name='eth1') Interface(device=device2, name='eth0'),
Cable(a_terminations=[interface1], b_terminations=[interface2]).save() 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_panel = Device.objects.create(
patch_pannel = Device.objects.create(
device_type=devicetype, role=role, name='TestPatchPanel', site=site device_type=devicetype, role=role, name='TestPatchPanel', site=site
) )
rear_port1 = RearPort.objects.create(device=patch_pannel, name='RP1', type='8p8c') rear_ports = (
front_port1 = FrontPort.objects.create( RearPort(device=patch_panel, name='RP1', type='8p8c'),
device=patch_pannel, name='FP1', type='8p8c', rear_port=rear_port1, rear_port_position=1 RearPort(device=patch_panel, name='RP2', type='8p8c', positions=2),
RearPort(device=patch_panel, name='RP3', type='8p8c', positions=3),
RearPort(device=patch_panel, name='RP4', type='8p8c', positions=3),
) )
rear_port2 = RearPort.objects.create(device=patch_pannel, name='RP2', type='8p8c', positions=2) RearPort.objects.bulk_create(rear_ports)
front_port2 = FrontPort.objects.create( front_ports = (
device=patch_pannel, name='FP2', type='8p8c', rear_port=rear_port2, rear_port_position=1 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),
rear_port3 = RearPort.objects.create(device=patch_pannel, name='RP3', type='8p8c', positions=3) FrontPort(device=patch_panel, name='FP3', type='8p8c', rear_port=rear_ports[2], rear_port_position=1),
front_port3 = FrontPort.objects.create( FrontPort(device=patch_panel, name='FP4', type='8p8c', rear_port=rear_ports[3], rear_port_position=1),
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
) )
FrontPort.objects.bulk_create(front_ports)
provider = Provider.objects.create(name='Provider 1', slug='provider-1') provider = Provider.objects.create(name='Provider 1', slug='provider-1')
provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=provider) provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=provider)
circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
circuit1 = Circuit.objects.create(provider=provider, type=circuittype, cid='1') circuit1 = Circuit.objects.create(provider=provider, type=circuittype, cid='1')
circuit2 = Circuit.objects.create(provider=provider, type=circuittype, cid='2') circuit2 = Circuit.objects.create(provider=provider, type=circuittype, cid='2')
circuittermination1 = CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='A') CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='A')
circuittermination2 = CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='Z') 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=circuit2, provider_network=provider_network, term_side='A')
def test_cable_creation(self): def test_cable_creation(self):
""" """

View File

@ -2571,7 +2571,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
} }
cls.csv_data = ( 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 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 5,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
f"Device 1,Interface 6,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af", f"Device 1,Interface 6,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",

View File

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

View File

@ -24,7 +24,7 @@ __all__ = [
# TODO: Remove in v4.2 # TODO: Remove in v4.2
warnings.warn( 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 DeprecationWarning
) )

View File

@ -1,5 +1,3 @@
from rest_framework import serializers
from core.api.serializers_.data import DataFileSerializer, DataSourceSerializer from core.api.serializers_.data import DataFileSerializer, DataSourceSerializer
from extras.models import ConfigTemplate from extras.models import ConfigTemplate
from netbox.api.serializers import ValidatedModelSerializer from netbox.api.serializers import ValidatedModelSerializer

View File

@ -1,5 +1,3 @@
from rest_framework import serializers
from core.models import ObjectType from core.models import ObjectType
from extras.models import CustomLink from extras.models import CustomLink
from netbox.api.fields import ContentTypeField from netbox.api.fields import ContentTypeField

View File

@ -1,5 +1,3 @@
from rest_framework import serializers
from core.api.serializers_.data import DataFileSerializer, DataSourceSerializer from core.api.serializers_.data import DataFileSerializer, DataSourceSerializer
from core.models import ObjectType from core.models import ObjectType
from extras.models import ExportTemplate from extras.models import ExportTemplate

View File

@ -1,5 +1,3 @@
from rest_framework import serializers
from core.models import ObjectType from core.models import ObjectType
from extras.models import SavedFilter from extras.models import SavedFilter
from netbox.api.fields import ContentTypeField from netbox.api.fields import ContentTypeField

View File

@ -1,5 +1,3 @@
from rest_framework import serializers
from core.models import ObjectType from core.models import ObjectType
from extras.models import Tag from extras.models import Tag
from netbox.api.fields import ContentTypeField, RelatedObjectCountField from netbox.api.fields import ContentTypeField, RelatedObjectCountField

View File

@ -1,6 +1,5 @@
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.module_loading import import_string
from django_rq.queues import get_connection from django_rq.queues import get_connection
from drf_spectacular.utils import extend_schema, extend_schema_view from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework import status from rest_framework import status
@ -15,8 +14,8 @@ from rq import Worker
from core.models import ObjectType from core.models import ObjectType
from extras import filtersets from extras import filtersets
from extras.models import *
from extras.jobs import ScriptJob from extras.jobs import ScriptJob
from extras.models import *
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.features import SyncedDataMixin from netbox.api.features import SyncedDataMixin
from netbox.api.metadata import ContentTypeMetadata from netbox.api.metadata import ContentTypeMetadata

View File

@ -6,7 +6,7 @@ class ExtrasConfig(AppConfig):
def ready(self): def ready(self):
from netbox.models.features import register_models 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
register_models(*self.get_models()) register_models(*self.get_models())

View File

@ -15,7 +15,6 @@ from django.utils.translation import gettext as _
from core.models import ObjectType from core.models import ObjectType
from extras.choices import BookmarkOrderingChoices from extras.choices import BookmarkOrderingChoices
from netbox.choices import ButtonColorChoices
from utilities.object_types import object_type_identifier, object_type_name from utilities.object_types import object_type_identifier, object_type_name
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
from utilities.querydict import dict_to_querydict from utilities.querydict import dict_to_querydict

View File

@ -84,7 +84,7 @@ class CustomFieldType(ObjectType):
class CustomFieldChoiceSetType(ObjectType): class CustomFieldChoiceSetType(ObjectType):
choices_for: List[Annotated["CustomFieldType", strawberry.lazy('extras.graphql.types')]] choices_for: List[Annotated["CustomFieldType", strawberry.lazy('extras.graphql.types')]]
extra_choices: List[str] | None extra_choices: List[List[str]] | None
@strawberry_django.type( @strawberry_django.type(

View File

@ -48,7 +48,7 @@ class ScriptJob(JobRunner):
except AbortTransaction: except AbortTransaction:
script.log_info(message=_("Database changes have been reverted automatically.")) script.log_info(message=_("Database changes have been reverted automatically."))
if script.failed: if script.failed:
logger.warning(f"Script failed") logger.warning("Script failed")
raise raise
except Exception as e: except Exception as e:

View File

@ -1,4 +1,5 @@
from django.db.models import CharField, TextField, Lookup from django.db.models import CharField, Lookup
from .fields import CachedValueField from .fields import CachedValueField

View File

@ -95,7 +95,7 @@ class Command(BaseCommand):
self.stdout.write("[*] Checking for latest release") self.stdout.write("[*] Checking for latest release")
if settings.ISOLATED_DEPLOYMENT: if settings.ISOLATED_DEPLOYMENT:
if options['verbosity']: 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: elif settings.RELEASE_CHECK_URL:
headers = { headers = {
'Accept': 'application/vnd.github.v3+json', 'Accept': 'application/vnd.github.v3+json',
@ -129,7 +129,7 @@ class Command(BaseCommand):
self.stdout.write(f"\tRequest error: {exc}", self.style.ERROR) self.stdout.write(f"\tRequest error: {exc}", self.style.ERROR)
else: else:
if options['verbosity']: 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']: if options['verbosity']:
self.stdout.write("Finished.", self.style.SUCCESS) self.stdout.write("Finished.", self.style.SUCCESS)

View File

@ -96,9 +96,9 @@ class Command(BaseCommand):
if i: if i:
self.stdout.write(f'{i} entries cached.') self.stdout.write(f'{i} entries cached.')
else: 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: if total_count := search_backend.size:
msg += f' Total entries: {total_count}' msg += f' Total entries: {total_count}'
self.stdout.write(msg, self.style.SUCCESS) self.stdout.write(msg, self.style.SUCCESS)

View File

@ -51,7 +51,7 @@ class Command(BaseCommand):
user = User.objects.filter(is_superuser=True).order_by('pk')[0] user = User.objects.filter(is_superuser=True).order_by('pk')[0]
# Setup logging to Stdout # 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 = logging.StreamHandler(sys.stdout)
stdouthandler.setLevel(logging.DEBUG) stdouthandler.setLevel(logging.DEBUG)
stdouthandler.setFormatter(formatter) stdouthandler.setFormatter(formatter)

View File

@ -283,7 +283,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
""" """
for ct in content_types: for ct in content_types:
model = ct.model_class() 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: for instance in instances:
instance.custom_field_data[self.name] = self.default instance.custom_field_data[self.name] = self.default
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100) 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.")) raise ValidationError(_("Value must be an integer."))
if self.validation_minimum is not None and value < self.validation_minimum: if self.validation_minimum is not None and value < self.validation_minimum:
raise ValidationError( 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: if self.validation_maximum is not None and value > self.validation_maximum:
raise ValidationError( raise ValidationError(
@ -785,6 +785,12 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel
def __str__(self): def __str__(self):
return self.name 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): def get_absolute_url(self):
return reverse('extras:customfieldchoiceset', args=[self.pk]) 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: if not self.base_choices and not self.extra_choices:
raise ValidationError(_("Must define base or 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): def save(self, *args, **kwargs):
# Sort choices if alphabetical ordering is enforced # Sort choices if alphabetical ordering is enforced

View File

@ -554,7 +554,7 @@ class BaseScript:
""" """
Run the report and save its results. Each test method will be executed in order. 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: try:
for test_name in self.tests: for test_name in self.tests:

View File

@ -12,7 +12,6 @@ from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Loca
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
from extras.scripts import BooleanVar, IntegerVar, Script as PythonClass, StringVar from extras.scripts import BooleanVar, IntegerVar, Script as PythonClass, StringVar
from netbox.events import *
from users.models import Group, User from users.models import Group, User
from utilities.testing import APITestCase, APIViewTestCases from utilities.testing import APITestCase, APIViewTestCases
@ -244,9 +243,18 @@ class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
choice_sets = ( choice_sets = (
CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['1A', '1B', '1C', '1D', '1E']), CustomFieldChoiceSet(
CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['2A', '2B', '2C', '2D', '2E']), name='Choice Set 1',
CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['3A', '3B', '3C', '3D', '3E']), 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) CustomFieldChoiceSet.objects.bulk_create(choice_sets)
@ -784,7 +792,6 @@ class ScriptTest(APITestCase):
super().setUp() super().setUp()
# Monkey-patch the Script model to return our TestScriptClass above # Monkey-patch the Script model to return our TestScriptClass above
from extras.api.views import ScriptViewSet
Script.python_class = self.python_class Script.python_class = self.python_class
def test_get_script(self): def test_get_script(self):

View File

@ -343,6 +343,74 @@ class CustomFieldTest(TestCase):
instance.refresh_from_db() instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name)) 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): def test_object_field(self):
value = VLAN.objects.create(name='VLAN 1', vid=1).pk value = VLAN.objects.create(name='VLAN 1', vid=1).pk

View File

@ -162,7 +162,7 @@ class CustomValidatorTest(TestCase):
Site(name='abcdef123', slug='abcdef123').clean() Site(name='abcdef123', slug='abcdef123').clean()
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [region_validator]}) @override_settings(CUSTOM_VALIDATORS={'dcim.site': [region_validator]})
def test_valid(self): def test_related_object(self):
region1 = Region(name='Foo', slug='foo') region1 = Region(name='Foo', slug='foo')
region1.save() region1.save()
region2 = Region(name='Bar', slug='bar') region2 = Region(name='Bar', slug='bar')

View File

@ -49,11 +49,11 @@ class ConfigContextTest(TestCase):
sitegroup = SiteGroup.objects.create(name='Site Group') sitegroup = SiteGroup.objects.create(name='Site Group')
site = Site.objects.create(name='Site 1', slug='site-1', region=region, group=sitegroup) 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) 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') tenantgroup = TenantGroup.objects.create(name='Tenant Group')
tenant = Tenant.objects.create(name='Tenant', group=tenantgroup) Tenant.objects.create(name='Tenant', group=tenantgroup)
tag1 = Tag.objects.create(name='Tag', slug='tag') Tag.objects.create(name='Tag', slug='tag')
tag2 = Tag.objects.create(name='Tag2', slug='tag2') Tag.objects.create(name='Tag2', slug='tag2')
Device.objects.create( Device.objects.create(
name='Device 1', name='Device 1',

View File

@ -417,7 +417,7 @@ class EventRulesTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
cls.csv_data = ( 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', f'Webhook 4,dcim.site,"{OBJECT_CREATED},{OBJECT_UPDATED}",webhook,Webhook 1',
) )

View File

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

View File

@ -6,8 +6,8 @@ from django.db.models import Count, Q
from django.http import HttpResponseBadRequest, HttpResponseForbidden, HttpResponse from django.http import HttpResponseBadRequest, HttpResponseForbidden, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils.module_loading import import_string
from django.utils import timezone from django.utils import timezone
from django.utils.module_loading import import_string
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.generic import View 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.forms import DashboardWidgetAddForm, DashboardWidgetForm
from extras.dashboard.utils import get_widget_class from extras.dashboard.utils import get_widget_class
from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.registry import registry
from netbox.views import generic from netbox.views import generic
from netbox.views.generic.mixins import TableMixin from netbox.views.generic.mixins import TableMixin
from utilities.forms import ConfirmationForm, get_field_value from utilities.forms import ConfirmationForm, get_field_value

View File

@ -30,7 +30,7 @@ __all__ = [
# TODO: Remove in v4.2 # TODO: Remove in v4.2
warnings.warn( 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 DeprecationWarning
) )

View File

@ -1,5 +1,3 @@
from rest_framework import serializers
from ipam.models import Role from ipam.models import Role
from netbox.api.fields import RelatedObjectCountField from netbox.api.fields import RelatedObjectCountField
from netbox.api.serializers import NetBoxModelSerializer from netbox.api.serializers import NetBoxModelSerializer

View File

@ -1,5 +1,3 @@
from rest_framework import serializers
from dcim.api.serializers_.devices import DeviceSerializer from dcim.api.serializers_.devices import DeviceSerializer
from ipam.choices import * from ipam.choices import *
from ipam.models import IPAddress, Service, ServiceTemplate from ipam.models import IPAddress, Service, ServiceTemplate

View File

@ -1,5 +1,3 @@
from rest_framework import serializers
from ipam.models import RouteTarget, VRF from ipam.models import RouteTarget, VRF
from netbox.api.fields import RelatedObjectCountField, SerializedPKRelatedField from netbox.api.fields import RelatedObjectCountField, SerializedPKRelatedField
from netbox.api.serializers import NetBoxModelSerializer from netbox.api.serializers import NetBoxModelSerializer

View File

@ -186,13 +186,13 @@ class AvailableObjectsView(ObjectValidationMixin, APIView):
""" """
Return the parent object. Return the parent object.
""" """
raise NotImplemented() raise NotImplementedError()
def get_available_objects(self, parent, limit=None): def get_available_objects(self, parent, limit=None):
""" """
Return all available objects for the parent. Return all available objects for the parent.
""" """
raise NotImplemented() raise NotImplementedError()
def get_extra_context(self, parent): def get_extra_context(self, parent):
""" """
@ -250,7 +250,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView):
# Determine if the requested number of objects is available # Determine if the requested number of objects is available
if not self.check_sufficient_available(serializer.validated_data, available_objects): if not self.check_sufficient_available(serializer.validated_data, available_objects):
return Response( 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 status=status.HTTP_409_CONFLICT
) )

View File

@ -7,7 +7,7 @@ class IPAMConfig(AppConfig):
def ready(self): def ready(self):
from netbox.models.features import register_models from netbox.models.features import register_models
from . import signals, search from . import signals, search # noqa: F401
# Register models # Register models
register_models(*self.get_models()) register_models(*self.get_models())

View File

@ -1035,6 +1035,16 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
to_field_name='identifier', to_field_name='identifier',
label=_('L2VPN'), 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: class Meta:
model = VLAN model = VLAN
@ -1062,6 +1072,22 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
def get_for_virtualmachine(self, queryset, name, value): def get_for_virtualmachine(self, queryset, name, value):
return queryset.get_for_virtualmachine(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): class ServiceTemplateFilterSet(NetBoxModelFilterSet):
port = NumericArrayFilter( port = NumericArrayFilter(

View File

@ -1,7 +1,6 @@
from typing import Annotated, List from typing import Annotated, List
import strawberry import strawberry
import strawberry_django
__all__ = ( __all__ = (
'IPAddressesMixin', 'IPAddressesMixin',
@ -11,9 +10,9 @@ __all__ = (
@strawberry.type @strawberry.type
class IPAddressesMixin: 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 @strawberry.type
class VLANGroupsMixin: class VLANGroupsMixin:
vlan_groups: List[Annotated["VLANGroupType", strawberry.lazy('ipam.graphql.types')]] vlan_groups: List[Annotated["VLANGroupType", strawberry.lazy('ipam.graphql.types')]] # noqa: F821

View File

@ -51,7 +51,7 @@ IPADDRESS_LINK = """
{% if record.pk %} {% if record.pk %}
<a href="{{ record.get_absolute_url }}" id="ipaddress_{{ record.pk }}">{{ record.address }}</a> <a href="{{ record.get_absolute_url }}" id="ipaddress_{{ record.pk }}">{{ record.address }}</a>
{% elif perms.ipam.add_ipaddress %} {% 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 %} {% else %}
{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available {% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available
{% endif %} {% endif %}

View File

@ -700,8 +700,6 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase):
device1.primary_ip4 = ip_addresses[0] device1.primary_ip4 = ip_addresses[0]
device1.save() device1.save()
ip2 = ip_addresses[1]
url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': ip1.pk}) url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': ip1.pk})
self.add_permissions('ipam.change_ipaddress') self.add_permissions('ipam.change_ipaddress')

View File

@ -1658,6 +1658,13 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
) )
Device.objects.bulk_create(devices) 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 = ( cluster_groups = (
ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'), ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'),
ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'), ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'),
@ -1680,6 +1687,13 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
) )
VirtualMachine.objects.bulk_create(virtual_machines) 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 = ( groups = (
# Scoped VLAN groups # Scoped VLAN groups
VLANGroup(name='Region 1', slug='region-1', scope=regions[0]), VLANGroup(name='Region 1', slug='region-1', scope=regions[0]),
@ -1773,6 +1787,22 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
) )
VLAN.objects.bulk_create(vlans) 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): def test_q(self):
params = {'q': 'foobar1'} params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@ -1857,6 +1887,16 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'available_at_site': site_id} params = {'available_at_site': site_id}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) # 4 scoped + 1 global group + 1 global 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): class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = ServiceTemplate.objects.all() queryset = ServiceTemplate.objects.all()

View File

@ -50,7 +50,7 @@ class ASNRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
cls.csv_data = ( 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 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 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", 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 = ( cls.csv_data = (
f"name,slug,scope_type,scope_id,description", "name,slug,scope_type,scope_id,description",
f"VLAN Group 4,vlan-group-4,,,Fourth VLAN group", "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 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", f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].pk},Sixth VLAN group",
) )
cls.csv_update_data = ( 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[0].pk},VLAN Group 7,Fourth VLAN group7",
f"{vlan_groups[1].pk},VLAN Group 8,Fifth VLAN group8", f"{vlan_groups[1].pk},VLAN Group 8,Fifth VLAN group8",
f"{vlan_groups[2].pk},VLAN Group 9,Sixth VLAN group9", f"{vlan_groups[2].pk},VLAN Group 9,Sixth VLAN group9",

View File

@ -1,5 +1,3 @@
from rest_framework import serializers
from extras.models import Tag from extras.models import Tag
from utilities.api import get_related_object_by_attrs from utilities.api import get_related_object_by_attrs
from .base import BaseModelSerializer from .base import BaseModelSerializer

View File

@ -20,10 +20,10 @@ AUTH_BACKEND_ATTRS = {
'amazon': ('Amazon AWS', 'aws'), 'amazon': ('Amazon AWS', 'aws'),
'apple': ('Apple', 'apple'), 'apple': ('Apple', 'apple'),
'auth0': ('Auth0', None), 'auth0': ('Auth0', None),
'azuread-oauth2': ('Microsoft Azure AD', 'microsoft'), 'entraid-oauth2': ('Microsoft Entra ID', 'microsoft'),
'azuread-b2c-oauth2': ('Microsoft Azure AD', 'microsoft'), 'entraid-b2c-oauth2': ('Microsoft Entra ID', 'microsoft'),
'azuread-tenant-oauth2': ('Microsoft Azure AD', 'microsoft'), 'entraid-tenant-oauth2': ('Microsoft Entra ID', 'microsoft'),
'azuread-v2-tenant-oauth2': ('Microsoft Azure AD', 'microsoft'), 'entraid-v2-tenant-oauth2': ('Microsoft Entra ID', 'microsoft'),
'bitbucket': ('BitBucket', 'bitbucket'), 'bitbucket': ('BitBucket', 'bitbucket'),
'bitbucket-oauth2': ('BitBucket', 'bitbucket'), 'bitbucket-oauth2': ('BitBucket', 'bitbucket'),
'digitalocean': ('DigitalOcean', 'digital-ocean'), 'digitalocean': ('DigitalOcean', 'digital-ocean'),

View File

@ -85,7 +85,7 @@ class Config:
logger.debug("Loaded configuration data from database") logger.debug("Loaded configuration data from database")
except DatabaseError: except DatabaseError:
# The database may not be available yet (e.g. when running a management command) # 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 return
revision.activate() revision.activate()

View File

@ -50,4 +50,4 @@ class DataBackend:
2. Yields the local path at which data has been replicated 2. Yields the local path at which data has been replicated
3. Performs any necessary cleanup 3. Performs any necessary cleanup
""" """
raise NotImplemented() raise NotImplementedError()

View File

@ -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 # 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 # is the same as the default type for the field
resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid
for field_to_remove in ('choices', 'null_value'): filter_cls = type(existing_filter)
existing_filter_extra.pop(field_to_remove, None) if lookup_expr == 'empty':
filter_cls = django_filters.BooleanFilter if lookup_expr == 'empty' else type(existing_filter) 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( new_filter = filter_cls(
field_name=field_name, field_name=field_name,
lookup_expr=lookup_expr, lookup_expr=lookup_expr,

View File

@ -1,11 +1,12 @@
from functools import partial, partialmethod, wraps from functools import partialmethod
from typing import List from typing import List
import django_filters import django_filters
import strawberry import strawberry
import strawberry_django import strawberry_django
from django.core.exceptions import FieldDoesNotExist, ValidationError from django.core.exceptions import FieldDoesNotExist
from strawberry import auto from strawberry import auto
from ipam.fields import ASNField from ipam.fields import ASNField
from netbox.graphql.scalars import BigInt from netbox.graphql.scalars import BigInt
from utilities.fields import ColorField, CounterCacheField from utilities.fields import ColorField, CounterCacheField
@ -108,8 +109,7 @@ def map_strawberry_type(field):
elif issubclass(type(field), django_filters.TypedMultipleChoiceFilter): elif issubclass(type(field), django_filters.TypedMultipleChoiceFilter):
pass pass
elif issubclass(type(field), django_filters.MultipleChoiceFilter): elif issubclass(type(field), django_filters.MultipleChoiceFilter):
should_create_function = True attr_type = str | None
attr_type = List[str] | None
elif issubclass(type(field), django_filters.TypedChoiceFilter): elif issubclass(type(field), django_filters.TypedChoiceFilter):
pass pass
elif issubclass(type(field), django_filters.ChoiceFilter): elif issubclass(type(field), django_filters.ChoiceFilter):

View File

@ -386,57 +386,57 @@ ADMIN_MENU = Menu(
label=_('Authentication'), label=_('Authentication'),
items=( items=(
MenuItem( MenuItem(
link=f'users:user_list', link='users:user_list',
link_text=_('Users'), link_text=_('Users'),
auth_required=True, auth_required=True,
permissions=[f'users.view_user'], permissions=['users.view_user'],
buttons=( buttons=(
MenuItemButton( MenuItemButton(
link=f'users:user_add', link='users:user_add',
title='Add', title='Add',
icon_class='mdi mdi-plus-thick', icon_class='mdi mdi-plus-thick',
permissions=[f'users.add_user'] permissions=['users.add_user']
), ),
MenuItemButton( MenuItemButton(
link=f'users:user_import', link='users:user_import',
title='Import', title='Import',
icon_class='mdi mdi-upload', icon_class='mdi mdi-upload',
permissions=[f'users.add_user'] permissions=['users.add_user']
) )
) )
), ),
MenuItem( MenuItem(
link=f'users:group_list', link='users:group_list',
link_text=_('Groups'), link_text=_('Groups'),
auth_required=True, auth_required=True,
permissions=[f'users.view_group'], permissions=['users.view_group'],
buttons=( buttons=(
MenuItemButton( MenuItemButton(
link=f'users:group_add', link='users:group_add',
title='Add', title='Add',
icon_class='mdi mdi-plus-thick', icon_class='mdi mdi-plus-thick',
permissions=[f'users.add_group'] permissions=['users.add_group']
), ),
MenuItemButton( MenuItemButton(
link=f'users:group_import', link='users:group_import',
title='Import', title='Import',
icon_class='mdi mdi-upload', icon_class='mdi mdi-upload',
permissions=[f'users.add_group'] permissions=['users.add_group']
) )
) )
), ),
MenuItem( MenuItem(
link=f'users:token_list', link='users:token_list',
link_text=_('API Tokens'), link_text=_('API Tokens'),
auth_required=True, auth_required=True,
permissions=[f'users.view_token'], permissions=['users.view_token'],
buttons=get_model_buttons('users', 'token') buttons=get_model_buttons('users', 'token')
), ),
MenuItem( MenuItem(
link=f'users:objectpermission_list', link='users:objectpermission_list',
link_text=_('Permissions'), link_text=_('Permissions'),
auth_required=True, auth_required=True,
permissions=[f'users.view_objectpermission'], permissions=['users.view_objectpermission'],
buttons=get_model_buttons('users', 'objectpermission', actions=['add']) buttons=get_model_buttons('users', 'objectpermission', actions=['add'])
), ),
), ),

Some files were not shown because too many files have changed in this diff Show More