diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index 5140a0743..e5cbc2ece 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yaml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yaml
@@ -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/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/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/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/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/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 1e7a9d2cb..7088273bf 100644
--- a/netbox/netbox/navigation/menu.py
+++ b/netbox/netbox/navigation/menu.py
@@ -372,6 +372,7 @@ ADMIN_MENU = Menu(
MenuItem(
link=f'users:user_list',
link_text=_('Users'),
+ auth_required=True,
permissions=[f'auth.view_user'],
buttons=(
MenuItemButton(
@@ -391,6 +392,7 @@ ADMIN_MENU = Menu(
MenuItem(
link=f'users:group_list',
link_text=_('Groups'),
+ auth_required=True,
permissions=[f'auth.view_group'],
buttons=(
MenuItemButton(
@@ -410,12 +412,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'])
),
@@ -426,16 +430,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 53eaffed5..f2b01041f 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -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/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/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 #}