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 #}