diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index 5140a0743..21e66ec05 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yaml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yaml
@@ -26,7 +26,7 @@ body:
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
- placeholder: v4.0.0
+ placeholder: v4.0.1
validations:
required: true
- type: dropdown
@@ -34,10 +34,9 @@ body:
label: Python Version
description: What version of Python are you currently running?
options:
- - "3.8"
- - "3.9"
- "3.10"
- "3.11"
+ - "3.12"
validations:
required: true
- type: textarea
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml
index 83125b4fa..671fdbf87 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yaml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yaml
@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
- placeholder: v4.0.0
+ placeholder: v4.0.1
validations:
required: true
- type: dropdown
diff --git a/contrib/generated_schema.json b/contrib/generated_schema.json
index b6632dd4c..fe9d56b34 100644
--- a/contrib/generated_schema.json
+++ b/contrib/generated_schema.json
@@ -353,6 +353,8 @@
"800gbase-x-qsfpdd",
"800gbase-x-osfp",
"1000base-kx",
+ "2.5gbase-kx",
+ "5gbase-kr",
"10gbase-kr",
"10gbase-kx4",
"25gbase-kr",
diff --git a/contrib/uwsgi.ini b/contrib/uwsgi.ini
index d64803158..a8bedc1d7 100644
--- a/contrib/uwsgi.ini
+++ b/contrib/uwsgi.ini
@@ -11,8 +11,24 @@ master = true
; clear environment on exit
vacuum = true
+; make SIGTERM stop the app (instead of reload)
+die-on-term = true
+
; exit if no app can be loaded
need-app = true
; do not use multiple interpreters
single-interpreter = true
+
+; change to the project directory
+chdir = netbox
+
+; specify the WSGI module to load
+module = netbox.wsgi
+
+; workaround to make uWSGI reloads work with pyuwsgi (not to be used if using uwsgi package instead)
+binary-path = venv/bin/python
+
+; only log internal messages and errors (reverse proxy already logs the requests)
+disable-logging = true
+log-5xx = true
diff --git a/docs/development/adding-models.md b/docs/development/adding-models.md
index 7de897a97..823789641 100644
--- a/docs/development/adding-models.md
+++ b/docs/development/adding-models.md
@@ -77,7 +77,7 @@ Create the following for each model:
## 13. GraphQL API components
-Create a Graphene object type for the model in `graphql/types.py` by subclassing the appropriate class from `netbox.graphql.types`.
+Create a GraphQL object type for the model in `graphql/types.py` by subclassing the appropriate class from `netbox.graphql.types`.
Also extend the schema class defined in `graphql/schema.py` with the individual object and object list fields per the established convention.
diff --git a/docs/development/release-checklist.md b/docs/development/release-checklist.md
index 48f72aa9f..875b2b869 100644
--- a/docs/development/release-checklist.md
+++ b/docs/development/release-checklist.md
@@ -72,7 +72,7 @@ In cases where upgrading a dependency to its most recent release is breaking, it
### Update UI Dependencies
-Check whether any UI dependencies (JavaScript packages, fonts, etc.) need to be updated by running `yarn outdated` from within the `project-static/` directory. [Upgrade these dependencies](http://0.0.0.0:9000/development/web-ui/#updating-dependencies) as necessary, then run `yarn bundle` to generate the necessary files for distribution.
+Check whether any UI dependencies (JavaScript packages, fonts, etc.) need to be updated by running `yarn outdated` from within the `project-static/` directory. [Upgrade these dependencies](./web-ui.md#updating-dependencies) as necessary, then run `yarn bundle` to generate the necessary files for distribution.
### Rebuild the Device Type Definition Schema
diff --git a/docs/installation/4b-uwsgi.md b/docs/installation/4b-uwsgi.md
index 3b7b5f76c..c8d1437a0 100644
--- a/docs/installation/4b-uwsgi.md
+++ b/docs/installation/4b-uwsgi.md
@@ -17,7 +17,7 @@ pip3 install pyuwsgi
Once installed, add the package to `local_requirements.txt` to ensure it is re-installed during future rebuilds of the virtual environment:
```no-highlight
-sudo sh -c "echo 'pyuwgsi' >> /opt/netbox/local_requirements.txt"
+sudo sh -c "echo 'pyuwsgi' >> /opt/netbox/local_requirements.txt"
```
## Configuration
diff --git a/docs/integrations/graphql-api.md b/docs/integrations/graphql-api.md
index 724e4e73d..3ccb4d4a1 100644
--- a/docs/integrations/graphql-api.md
+++ b/docs/integrations/graphql-api.md
@@ -1,6 +1,6 @@
# GraphQL API Overview
-NetBox provides a read-only [GraphQL](https://graphql.org/) API to complement its REST API. This API is powered by the [Graphene](https://graphene-python.org/) library and [Graphene-Django](https://docs.graphene-python.org/projects/django/en/latest/).
+NetBox provides a read-only [GraphQL](https://graphql.org/) API to complement its REST API. This API is powered by [Strawberry Django](https://strawberry-graphql.github.io/strawberry-django/).
## Queries
@@ -47,7 +47,7 @@ NetBox provides both a singular and plural query field for each object type:
For example, query `device(id:123)` to fetch a specific device (identified by its unique ID), and query `device_list` (with an optional set of filters) to fetch all devices.
-For more detail on constructing GraphQL queries, see the [Graphene documentation](https://docs.graphene-python.org/en/latest/) as well as the [GraphQL queries documentation](https://graphql.org/learn/queries/).
+For more detail on constructing GraphQL queries, see the [GraphQL queries documentation](https://graphql.org/learn/queries/). For filtering and lookup syntax, please refer to the [Strawberry Django documentation](https://strawberry-graphql.github.io/strawberry-django/guide/filters/).
## Filtering
diff --git a/docs/plugins/development/graphql-api.md b/docs/plugins/development/graphql-api.md
index 05f11704c..603b0cead 100644
--- a/docs/plugins/development/graphql-api.md
+++ b/docs/plugins/development/graphql-api.md
@@ -2,7 +2,7 @@
## Defining the Schema Class
-A plugin can extend NetBox's GraphQL API by registering its own schema class. By default, NetBox will attempt to import `graphql.schema` from the plugin, if it exists. This path can be overridden by defining `graphql_schema` on the PluginConfig instance as the dotted path to the desired Python class. This class must be a subclass of `graphene.ObjectType`.
+A plugin can extend NetBox's GraphQL API by registering its own schema class. By default, NetBox will attempt to import `graphql.schema` from the plugin, if it exists. This path can be overridden by defining `graphql_schema` on the PluginConfig instance as the dotted path to the desired Python class.
### Example
diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md
index 9aa75ce4a..c042be6ec 100644
--- a/docs/plugins/development/index.md
+++ b/docs/plugins/development/index.md
@@ -55,18 +55,20 @@ project-name/
- template_content.py
- urls.py
- views.py
+ - pyproject.toml
- README.md
- - setup.py
```
The top level is the project root, which can have any name that you like. Immediately within the root should exist several items:
-* `setup.py` - This is a standard installation script used to install the plugin package within the Python environment.
+* `pyproject.toml` - is a standard configuration file used to install the plugin package within the Python environment.
* `README.md` - A brief introduction to your plugin, how to install and configure it, where to find help, and any other pertinent information. It is recommended to write `README` files using a markup language such as Markdown to enable human-friendly display.
* The plugin source directory. This must be a valid Python package name, typically comprising only lowercase letters, numbers, and underscores.
The plugin source directory contains all the actual Python code and other resources used by your plugin. Its structure is left to the author's discretion, however it is recommended to follow best practices as outlined in the [Django documentation](https://docs.djangoproject.com/en/stable/intro/reusable-apps/). At a minimum, this directory **must** contain an `__init__.py` file containing an instance of NetBox's `PluginConfig` class, discussed below.
+**Note:** The [Cookiecutter NetBox Plugin](https://github.com/netbox-community/cookiecutter-netbox-plugin) can be used to auto-generate all the needed directories and files for a new plugin.
+
## PluginConfig
The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below:
@@ -136,31 +138,48 @@ Apps from this list are inserted *before* the plugin's `PluginConfig` in the ord
Any additional apps must be installed within the same Python environment as NetBox or `ImproperlyConfigured` exceptions will be raised when loading the plugin.
-## Create setup.py
+## Create pyproject.toml
-`setup.py` is the [setup script](https://docs.python.org/3.10/distutils/setupscript.html) used to package and install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to control the package creation as well as to provide metadata about the plugin. An example `setup.py` is below:
+`pyproject.toml` is the [configuration file](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/) used to package and install our plugin once it's finished. It is used by packaging tools, as well as other tools. The primary function of this file is to call the build system to create a Python distribution package. We can pass a number of keyword arguments to control the package creation as well as to provide metadata about the plugin. There are three possible TOML tables in this file:
-```python
-from setuptools import find_packages, setup
+* `[build-system]` allows you to declare which build backend you use and which other dependencies (if any) are needed to build your project.
+* `[project]` is the format that most build backends use to specify your project’s basic metadata, such as the author's name, project URL, etc.
+* `[tool]` has tool-specific subtables, e.g., `[tool.black]`, `[tool.mypy]`. Consult the particular tool’s documentation for reference.
+
+An example `pyproject.toml` is below:
+
+```
+# See PEP 518 for the spec of this file
+# https://www.python.org/dev/peps/pep-0518/
+
+[build-system]
+requires = ["setuptools"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "my-example-plugin"
+version = "0.1.0"
+authors = [
+ {name = "John Doe", email = "test@netboxlabs.com"},
+]
+description = "An example NetBox plugin."
+readme = "README.md"
+
+classifiers=[
+ 'Development Status :: 3 - Alpha',
+ 'Intended Audience :: Developers',
+ 'Natural Language :: English',
+ "Programming Language :: Python :: 3 :: Only",
+ 'Programming Language :: Python :: 3.10',
+ 'Programming Language :: Python :: 3.11',
+ 'Programming Language :: Python :: 3.12',
+]
+
+requires-python = ">=3.10.0"
-setup(
- name='my-example-plugin',
- version='0.1',
- description='An example NetBox plugin',
- url='https://github.com/jeremystretch/my-example-plugin',
- author='Jeremy Stretch',
- license='Apache 2.0',
- install_requires=[],
- packages=find_packages(),
- include_package_data=True,
- zip_safe=False,
-)
```
-Many of these are self-explanatory, but for more information, see the [setuptools documentation](https://setuptools.readthedocs.io/en/latest/setuptools.html).
-
-!!! info
- `zip_safe=False` is **required** as the current plugin iteration is not zip safe due to upstream python issue [issue19699](https://bugs.python.org/issue19699)
+Many of these are self-explanatory, but for more information, see the [pyproject.toml documentation](https://packaging.python.org/en/latest/specifications/pyproject-toml/).
## Create a Virtual Environment
@@ -178,11 +197,12 @@ echo /opt/netbox/netbox > $VENV/lib/python3.10/site-packages/netbox.pth
## Development Installation
-To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' `develop` mode. This will create symbolic links within your Python environment to the plugin development directory. Call `setup.py` from the plugin's root directory with the `develop` argument (instead of `install`):
+To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' `develop` mode. This will create symbolic links within your Python environment to the plugin development directory. Call `pip` from the plugin's root directory with the `-e` flag:
```no-highlight
-$ python setup.py develop
+$ pip install -e .
```
+More information on editable builds can be found at [Editable installs for pyproject.toml ](https://peps.python.org/pep-0660/).
## Configure NetBox
diff --git a/docs/release-notes/version-4.0.md b/docs/release-notes/version-4.0.md
index 4f8b0f29a..42926243b 100644
--- a/docs/release-notes/version-4.0.md
+++ b/docs/release-notes/version-4.0.md
@@ -1,5 +1,34 @@
# NetBox v4.0
+## v4.0.1 (2024-05-09)
+
+### Enhancements
+
+* [#15148](https://github.com/netbox-community/netbox/issues/15148) - Add copy-to-clipboard button for config context data
+* [#15328](https://github.com/netbox-community/netbox/issues/15328) - Add a virtual machines UI tab for host devices
+* [#15451](https://github.com/netbox-community/netbox/issues/15451) - Add 2.5 and 5 Gbps backplane Ethernet interface types
+* [#16010](https://github.com/netbox-community/netbox/issues/16010) - Enable Prometheus middleware only if metrics are enabled
+
+### Bug Fixes
+
+* [#15968](https://github.com/netbox-community/netbox/issues/15968) - Avoid resizing quick search field to display clear button
+* [#15973](https://github.com/netbox-community/netbox/issues/15973) - Fix AttributeError exception when modifying cable termination type
+* [#15977](https://github.com/netbox-community/netbox/issues/15977) - Hide all admin menu items for non-authenticated users
+* [#15982](https://github.com/netbox-community/netbox/issues/15982) - Restore the "assign IP" tab for assigning existing IP addresses to interfaces
+* [#15992](https://github.com/netbox-community/netbox/issues/15992) - Fix AttributeError exception when Sentry integration is enabled
+* [#15995](https://github.com/netbox-community/netbox/issues/15995) - Permit nullable fields referenced by unique constraints to be omitted from REST API requests
+* [#15999](https://github.com/netbox-community/netbox/issues/15999) - Fix layout of login form labels for certain languages
+* [#16003](https://github.com/netbox-community/netbox/issues/16003) - Enable cache busting for `setmode.js` asset to avoid breaking dark mode support on upgrade
+* [#16011](https://github.com/netbox-community/netbox/issues/16011) - Fix site tenant assignment by PK via REST API
+* [#16020](https://github.com/netbox-community/netbox/issues/16020) - Include Python version in system UI view
+* [#16022](https://github.com/netbox-community/netbox/issues/16022) - Fix database migration failure when encountering a script module which no longer exists on disk
+* [#16025](https://github.com/netbox-community/netbox/issues/16025) - Fix execution of scripts via the `runscript` management command
+* [#16031](https://github.com/netbox-community/netbox/issues/16031) - Render Markdown content in script log messages
+* [#16051](https://github.com/netbox-community/netbox/issues/16051) - Translate "empty" text for object tables
+* [#16061](https://github.com/netbox-community/netbox/issues/16061) - Omit hidden fields from display within event rule edit form
+
+---
+
## v4.0.0 (2024-05-06)
!!! tip "Plugin Maintainers"
diff --git a/netbox/circuits/api/serializers_/circuits.py b/netbox/circuits/api/serializers_/circuits.py
index b59c73f09..a0d0e5e13 100644
--- a/netbox/circuits/api/serializers_/circuits.py
+++ b/netbox/circuits/api/serializers_/circuits.py
@@ -48,7 +48,7 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
class CircuitSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
provider = ProviderSerializer(nested=True)
- provider_account = ProviderAccountSerializer(nested=True, required=False, allow_null=True)
+ provider_account = ProviderAccountSerializer(nested=True, required=False, allow_null=True, default=None)
status = ChoiceField(choices=CircuitStatusChoices, required=False)
type = CircuitTypeSerializer(nested=True)
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
diff --git a/netbox/circuits/api/serializers_/providers.py b/netbox/circuits/api/serializers_/providers.py
index 302c2da5a..fa4489787 100644
--- a/netbox/circuits/api/serializers_/providers.py
+++ b/netbox/circuits/api/serializers_/providers.py
@@ -45,6 +45,7 @@ class ProviderSerializer(NetBoxModelSerializer):
class ProviderAccountSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provideraccount-detail')
provider = ProviderSerializer(nested=True)
+ name = serializers.CharField(allow_blank=True, max_length=100, required=False, default='')
class Meta:
model = ProviderAccount
diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py
index c8ec08943..d3745f2b1 100644
--- a/netbox/circuits/tests/test_api.py
+++ b/netbox/circuits/tests/test_api.py
@@ -141,7 +141,7 @@ class CircuitTest(APIViewTestCases.APIViewTestCase):
{
'cid': 'Circuit 6',
'provider': providers[1].pk,
- 'provider_account': provider_accounts[1].pk,
+ # Omit provider account to test uniqueness constraint
'type': circuit_types[1].pk,
},
]
@@ -237,7 +237,7 @@ class ProviderAccountTest(APIViewTestCases.APIViewTestCase):
'account': '5678',
},
{
- 'name': 'Provider Account 6',
+ # Omit name to test uniqueness constraint
'provider': providers[0].pk,
'account': '6789',
},
diff --git a/netbox/dcim/api/serializers_/devices.py b/netbox/dcim/api/serializers_/devices.py
index 7d1601882..edfac3072 100644
--- a/netbox/dcim/api/serializers_/devices.py
+++ b/netbox/dcim/api/serializers_/devices.py
@@ -122,6 +122,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
class VirtualDeviceContextSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail')
device = DeviceSerializer(nested=True)
+ identifier = serializers.IntegerField(allow_null=True, max_value=32767, min_value=0, required=False, default=None)
tenant = TenantSerializer(nested=True, required=False, allow_null=True, default=None)
primary_ip = IPAddressSerializer(nested=True, read_only=True, allow_null=True)
primary_ip4 = IPAddressSerializer(nested=True, required=False, allow_null=True)
diff --git a/netbox/dcim/api/serializers_/sites.py b/netbox/dcim/api/serializers_/sites.py
index 8063278a7..1e5e41069 100644
--- a/netbox/dcim/api/serializers_/sites.py
+++ b/netbox/dcim/api/serializers_/sites.py
@@ -51,7 +51,7 @@ class SiteSerializer(NetBoxModelSerializer):
status = ChoiceField(choices=SiteStatusChoices, required=False)
region = RegionSerializer(nested=True, required=False, allow_null=True)
group = SiteGroupSerializer(nested=True, required=False, allow_null=True)
- tenant = TenantSerializer(required=False, allow_null=True)
+ tenant = TenantSerializer(nested=True, required=False, allow_null=True)
time_zone = TimeZoneSerializerField(required=False, allow_null=True)
asns = SerializedPKRelatedField(
queryset=ASN.objects.all(),
@@ -83,7 +83,7 @@ class SiteSerializer(NetBoxModelSerializer):
class LocationSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail')
site = SiteSerializer(nested=True)
- parent = NestedLocationSerializer(required=False, allow_null=True)
+ parent = NestedLocationSerializer(required=False, allow_null=True, default=None)
status = ChoiceField(choices=LocationStatusChoices, required=False)
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
rack_count = serializers.IntegerField(read_only=True)
diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py
index b00784265..a21530d59 100644
--- a/netbox/dcim/choices.py
+++ b/netbox/dcim/choices.py
@@ -848,6 +848,8 @@ class InterfaceTypeChoices(ChoiceSet):
# Ethernet Backplane
TYPE_1GE_KX = '1000base-kx'
+ TYPE_2GE_KX = '2.5gbase-kx'
+ TYPE_5GE_KR = '5gbase-kr'
TYPE_10GE_KR = '10gbase-kr'
TYPE_10GE_KX4 = '10gbase-kx4'
TYPE_25GE_KR = '25gbase-kr'
@@ -1008,6 +1010,8 @@ class InterfaceTypeChoices(ChoiceSet):
_('Ethernet (backplane)'),
(
(TYPE_1GE_KX, '1000BASE-KX (1GE)'),
+ (TYPE_2GE_KX, '2.5GBASE-KX (2.5GE)'),
+ (TYPE_5GE_KR, '5GBASE-KR (5GE)'),
(TYPE_10GE_KR, '10GBASE-KR (10GE)'),
(TYPE_10GE_KX4, '10GBASE-KX4 (10GE)'),
(TYPE_25GE_KR, '25GBASE-KR (25GE)'),
diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py
index b57f4ad2c..44bea047a 100644
--- a/netbox/dcim/forms/connections.py
+++ b/netbox/dcim/forms/connections.py
@@ -90,14 +90,14 @@ def get_cable_form(a_type, b_type):
class _CableForm(CableForm, metaclass=FormMetaclass):
def __init__(self, *args, initial=None, **kwargs):
-
initial = initial or {}
+
if a_type:
- ct = ContentType.objects.get_for_model(a_type)
- initial['a_terminations_type'] = f'{ct.app_label}.{ct.model}'
+ a_ct = ContentType.objects.get_for_model(a_type)
+ initial['a_terminations_type'] = f'{a_ct.app_label}.{a_ct.model}'
if b_type:
- ct = ContentType.objects.get_for_model(b_type)
- initial['b_terminations_type'] = f'{ct.app_label}.{ct.model}'
+ b_ct = ContentType.objects.get_for_model(b_type)
+ initial['b_terminations_type'] = f'{b_ct.app_label}.{b_ct.model}'
# TODO: Temporary hack to work around list handling limitations with utils.normalize_querydict()
for field_name in ('a_terminations', 'b_terminations'):
@@ -108,8 +108,17 @@ def get_cable_form(a_type, b_type):
if self.instance and self.instance.pk:
# Initialize A/B terminations when modifying an existing Cable instance
- self.initial['a_terminations'] = self.instance.a_terminations
- self.initial['b_terminations'] = self.instance.b_terminations
+ if a_type and self.instance.a_terminations and a_ct == ContentType.objects.get_for_model(self.instance.a_terminations[0]):
+ self.initial['a_terminations'] = self.instance.a_terminations
+ if b_type and self.instance.b_terminations and b_ct == ContentType.objects.get_for_model(self.instance.b_terminations[0]):
+ self.initial['b_terminations'] = self.instance.b_terminations
+ else:
+ # Need to clear terminations if swapped type - but need to do it only
+ # if not from instance
+ if a_type:
+ initial.pop('a_terminations', None)
+ if b_type:
+ initial.pop('b_terminations', None)
def clean(self):
super().clean()
diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py
index 0a3931696..52b850b24 100644
--- a/netbox/dcim/tests/test_api.py
+++ b/netbox/dcim/tests/test_api.py
@@ -10,6 +10,7 @@ from dcim.models import *
from extras.models import ConfigTemplate
from ipam.models import ASN, RIR, VLAN, VRF
from netbox.api.serializers import GenericObjectSerializer
+from tenancy.models import Tenant
from utilities.testing import APITestCase, APIViewTestCases, create_test_device
from virtualization.models import Cluster, ClusterType
from wireless.choices import WirelessChannelChoices
@@ -152,6 +153,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
Site.objects.bulk_create(sites)
rir = RIR.objects.create(name='RFC 6996', is_private=True)
+ tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
asns = [
ASN(asn=65000 + i, rir=rir) for i in range(8)
@@ -166,6 +168,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
'group': groups[1].pk,
'status': SiteStatusChoices.STATUS_ACTIVE,
'asns': [asns[0].pk, asns[1].pk],
+ 'tenant': tenant.pk,
},
{
'name': 'Site 5',
@@ -230,7 +233,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
'name': 'Test Location 6',
'slug': 'test-location-6',
'site': sites[1].pk,
- 'parent': parent_locations[1].pk,
+ # Omit parent to test uniqueness constraint
'status': LocationStatusChoices.STATUS_PLANNED,
},
]
@@ -2307,6 +2310,6 @@ class VirtualDeviceContextTest(APIViewTestCases.APIViewTestCase):
'device': devices[1].pk,
'status': 'active',
'name': 'VDC 3',
- 'identifier': 3,
+ # Omit identifier to test uniqueness constraint
},
]
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index ca37084f2..abe8301a2 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -28,7 +28,9 @@ from utilities.permissions import get_permission_for_model
from utilities.query import count_related
from utilities.query_functions import CollateAsChar
from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
+from virtualization.filtersets import VirtualMachineFilterSet
from virtualization.models import VirtualMachine
+from virtualization.tables import VirtualMachineTable
from . import filtersets, forms, tables
from .choices import DeviceFaceChoices
from .models import *
@@ -2085,6 +2087,25 @@ class DeviceRenderConfigView(generic.ObjectView):
}
+@register_model_view(Device, 'virtual-machines')
+class DeviceVirtualMachinesView(generic.ObjectChildrenView):
+ queryset = Device.objects.all()
+ child_model = VirtualMachine
+ table = VirtualMachineTable
+ filterset = VirtualMachineFilterSet
+ template_name = 'generic/object_children.html'
+ tab = ViewTab(
+ label=_('Virtual Machines'),
+ badge=lambda obj: VirtualMachine.objects.filter(cluster=obj.cluster, device=obj).count(),
+ weight=2200,
+ hide_if_empty=True,
+ permission='virtualization.view_virtualmachine'
+ )
+
+ def get_children(self, request, parent):
+ return self.child_model.objects.restrict(request.user, 'view').filter(cluster=parent.cluster, device=parent)
+
+
class DeviceBulkImportView(generic.BulkImportView):
queryset = Device.objects.all()
model_form = forms.DeviceImportForm
diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py
index 229d94232..1d7b69ac3 100644
--- a/netbox/extras/forms/model_forms.py
+++ b/netbox/extras/forms/model_forms.py
@@ -279,10 +279,7 @@ class EventRuleForm(NetBoxModelForm):
FieldSet('name', 'description', 'object_types', 'enabled', 'tags', name=_('Event Rule')),
FieldSet('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', name=_('Events')),
FieldSet('conditions', name=_('Conditions')),
- FieldSet(
- 'action_type', 'action_choice', 'action_object_type', 'action_object_id', 'action_data',
- name=_('Action')
- ),
+ FieldSet('action_type', 'action_choice', 'action_data', name=_('Action')),
)
class Meta:
diff --git a/netbox/extras/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py
index 160e8813f..ef1bd5141 100644
--- a/netbox/extras/management/commands/runscript.py
+++ b/netbox/extras/management/commands/runscript.py
@@ -85,6 +85,7 @@ class Command(BaseCommand):
module_name, script_name = script.split('.', 1)
module, script = get_module_and_script(module_name, script_name)
+ script = script.python_class
# Take user from command line if provided and exists, other
if options['user']:
diff --git a/netbox/extras/migrations/0109_script_model.py b/netbox/extras/migrations/0109_script_model.py
index 7570077a7..6bfd2c14c 100644
--- a/netbox/extras/migrations/0109_script_model.py
+++ b/netbox/extras/migrations/0109_script_model.py
@@ -60,7 +60,10 @@ def get_module_scripts(scriptmodule):
return cls.full_name.split(".", maxsplit=1)[1]
loader = SourceFileLoader(get_python_name(scriptmodule), get_full_path(scriptmodule))
- module = loader.load_module()
+ try:
+ module = loader.load_module()
+ except FileNotFoundError:
+ return {}
scripts = {}
ordered = getattr(module, 'script_order', [])
diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py
index 8da3ea93a..3738f3102 100644
--- a/netbox/extras/tables/tables.py
+++ b/netbox/extras/tables/tables.py
@@ -545,7 +545,7 @@ class ScriptResultsTable(BaseTable):
template_code="""{% load log_levels %}{% log_level record.status %}""",
verbose_name=_('Level')
)
- message = tables.Column(
+ message = columns.MarkdownColumn(
verbose_name=_('Message')
)
@@ -566,22 +566,17 @@ class ReportResultsTable(BaseTable):
time = tables.Column(
verbose_name=_('Time')
)
- status = tables.Column(
- empty_values=(),
- verbose_name=_('Level')
- )
status = tables.TemplateColumn(
template_code="""{% load log_levels %}{% log_level record.status %}""",
verbose_name=_('Level')
)
-
object = tables.Column(
verbose_name=_('Object')
)
url = tables.Column(
verbose_name=_('URL')
)
- message = tables.Column(
+ message = columns.MarkdownColumn(
verbose_name=_('Message')
)
diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py
index fb1c123c3..2ae380d63 100644
--- a/netbox/ipam/models/ip.py
+++ b/netbox/ipam/models/ip.py
@@ -574,7 +574,7 @@ class IPRange(PrimaryModel):
if not self.end_address > self.start_address:
raise ValidationError({
'end_address': _(
- "Ending address must be lower than the starting address ({start_address})"
+ "Ending address must be greater than the starting address ({start_address})"
).format(start_address=self.start_address)
})
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index 24d82d186..044474ec4 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -781,6 +781,7 @@ class IPAddressView(generic.ObjectView):
class IPAddressEditView(generic.ObjectEditView):
queryset = IPAddress.objects.all()
form = forms.IPAddressForm
+ template_name = 'ipam/ipaddress_edit.html'
def alter_object(self, obj, request, url_args, url_kwargs):
diff --git a/netbox/netbox/navigation/__init__.py b/netbox/netbox/navigation/__init__.py
index d13282f7e..b4f7dbd9f 100644
--- a/netbox/netbox/navigation/__init__.py
+++ b/netbox/netbox/navigation/__init__.py
@@ -32,6 +32,7 @@ class MenuItem:
link: str
link_text: str
permissions: Optional[Sequence[str]] = ()
+ auth_required: Optional[bool] = False
staff_only: Optional[bool] = False
buttons: Optional[Sequence[MenuItemButton]] = ()
diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py
index 4fe16e773..2a58b277e 100644
--- a/netbox/netbox/navigation/menu.py
+++ b/netbox/netbox/navigation/menu.py
@@ -371,6 +371,7 @@ ADMIN_MENU = Menu(
MenuItem(
link=f'users:user_list',
link_text=_('Users'),
+ auth_required=True,
permissions=[f'auth.view_user'],
buttons=(
MenuItemButton(
@@ -390,6 +391,7 @@ ADMIN_MENU = Menu(
MenuItem(
link=f'users:group_list',
link_text=_('Groups'),
+ auth_required=True,
permissions=[f'auth.view_group'],
buttons=(
MenuItemButton(
@@ -409,12 +411,14 @@ ADMIN_MENU = Menu(
MenuItem(
link=f'users:token_list',
link_text=_('API Tokens'),
+ auth_required=True,
permissions=[f'users.view_token'],
buttons=get_model_buttons('users', 'token')
),
MenuItem(
link=f'users:objectpermission_list',
link_text=_('Permissions'),
+ auth_required=True,
permissions=[f'users.view_objectpermission'],
buttons=get_model_buttons('users', 'objectpermission', actions=['add'])
),
@@ -425,16 +429,19 @@ ADMIN_MENU = Menu(
items=(
MenuItem(
link='core:system',
- link_text=_('System')
+ link_text=_('System'),
+ auth_required=True
),
MenuItem(
link='core:configrevision_list',
link_text=_('Configuration History'),
+ auth_required=True,
permissions=['core.view_configrevision']
),
MenuItem(
link='core:background_queue_list',
- link_text=_('Background Tasks')
+ link_text=_('Background Tasks'),
+ auth_required=True
),
),
),
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index 7c4c400be..8fba7cffc 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -25,7 +25,7 @@ from utilities.string import trailing_slash
# Environment setup
#
-VERSION = '4.0.0'
+VERSION = '4.0.1'
HOSTNAME = platform.node()
# Set the base directory two levels up
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -372,7 +372,6 @@ if not DJANGO_ADMIN_ENABLED:
# Middleware
MIDDLEWARE = [
"strawberry_django.middlewares.debug_toolbar.DebugToolbarMiddleware",
- 'django_prometheus.middleware.PrometheusBeforeMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
@@ -386,8 +385,14 @@ MIDDLEWARE = [
'netbox.middleware.RemoteUserMiddleware',
'netbox.middleware.CoreMiddleware',
'netbox.middleware.MaintenanceModeMiddleware',
- 'django_prometheus.middleware.PrometheusAfterMiddleware',
]
+if METRICS_ENABLED:
+ # If metrics are enabled, add the before & after Prometheus middleware
+ MIDDLEWARE = [
+ 'django_prometheus.middleware.PrometheusBeforeMiddleware',
+ *MIDDLEWARE,
+ 'django_prometheus.middleware.PrometheusAfterMiddleware',
+ ]
# URLs
ROOT_URLCONF = 'netbox.urls'
@@ -522,7 +527,6 @@ if SENTRY_ENABLED:
sentry_sdk.init(
dsn=SENTRY_DSN,
release=VERSION,
- integrations=[sentry_sdk.integrations.django.DjangoIntegration()],
sample_rate=SENTRY_SAMPLE_RATE,
traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE,
send_default_pii=True,
diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py
index d8db511a2..38f7248e6 100644
--- a/netbox/netbox/tables/tables.py
+++ b/netbox/netbox/tables/tables.py
@@ -52,7 +52,7 @@ class BaseTable(tables.Table):
# Set default empty_text if none was provided
if self.empty_text is None:
- self.empty_text = f"No {self._meta.model._meta.verbose_name_plural} found"
+ self.empty_text = _("No {model_name} found").format(model_name=self._meta.model._meta.verbose_name_plural)
# Determine the table columns to display by checking the following:
# 1. User's configuration for the table
diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js
index 6c0d46b53..58c419b3d 100644
Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ
diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map
index 14f5520e4..f70987c66 100644
Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ
diff --git a/netbox/project-static/package.json b/netbox/project-static/package.json
index 4a931232f..e11df2520 100644
--- a/netbox/project-static/package.json
+++ b/netbox/project-static/package.json
@@ -30,7 +30,7 @@
"gridstack": "10.1.2",
"htmx.org": "1.9.12",
"query-string": "9.0.0",
- "sass": "1.76.0",
+ "sass": "1.77.0",
"tom-select": "2.3.1",
"typeface-inter": "3.18.1",
"typeface-roboto-mono": "1.1.13"
diff --git a/netbox/project-static/src/search.ts b/netbox/project-static/src/search.ts
index 8275b126e..4be740196 100644
--- a/netbox/project-static/src/search.ts
+++ b/netbox/project-static/src/search.ts
@@ -10,9 +10,9 @@ function quickSearchEventHandler(event: Event): void {
const clearbtn = document.getElementById("quicksearch_clear") as HTMLAnchorElement;
if (isTruthy(clearbtn)) {
if (quicksearch.value === "") {
- clearbtn.classList.add("d-none");
+ clearbtn.classList.add("invisible");
} else {
- clearbtn.classList.remove("d-none");
+ clearbtn.classList.remove("invisible");
}
}
}
diff --git a/netbox/project-static/yarn.lock b/netbox/project-static/yarn.lock
index d5d5a642e..e7d96b9e5 100644
--- a/netbox/project-static/yarn.lock
+++ b/netbox/project-static/yarn.lock
@@ -2482,7 +2482,16 @@ safe-regex-test@^1.0.3:
es-errors "^1.3.0"
is-regex "^1.1.4"
-sass@1.76.0, sass@^1.7.3:
+sass@1.77.0:
+ version "1.77.0"
+ resolved "https://registry.yarnpkg.com/sass/-/sass-1.77.0.tgz#e736c69aff9fae4a4e6dae60a979eee9c942f321"
+ integrity sha512-eGj4HNfXqBWtSnvItNkn7B6icqH14i3CiCGbzMKs3BAPTq62pp9NBYsBgyN4cA+qssqo9r26lW4JSvlaUUWbgw==
+ dependencies:
+ chokidar ">=3.0.0 <4.0.0"
+ immutable "^4.0.0"
+ source-map-js ">=0.6.2 <2.0.0"
+
+sass@^1.7.3:
version "1.76.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.76.0.tgz#fe15909500735ac154f0dc7386d656b62b03987d"
integrity sha512-nc3LeqvF2FNW5xGF1zxZifdW3ffIz5aBb7I7tSvOoNu7z1RQ6pFt9MBuiPtjgaI62YWrM/txjWlOCFiGtf2xpw==
diff --git a/netbox/templates/base/base.html b/netbox/templates/base/base.html
index f7fa3fa50..7a7a4fe99 100644
--- a/netbox/templates/base/base.html
+++ b/netbox/templates/base/base.html
@@ -20,7 +20,7 @@
{# Initialize color mode #}