diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index be2aacff5..b43968731 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.5.1 + placeholder: v3.5.8 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 1f8fdebd4..e6a5e76c2 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -3,10 +3,13 @@ blank_issues_enabled: false contact_links: - name: 📖 Contributing Policy url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md - about: "Please read through our contributing policy before opening an issue or pull request" + about: "Please read through our contributing policy before opening an issue or pull request." - name: ❓ Discussion url: https://github.com/netbox-community/netbox/discussions - about: "If you're just looking for help, try starting a discussion instead" + about: "If you're just looking for help, try starting a discussion instead." + - name: 💡 Plugin Idea + url: https://plugin-ideas.netbox.dev + about: "Have an idea for a plugin? Head over to the ideas board!" - name: 💬 Community Slack - url: https://netdev.chat/ - about: "Join #netbox on the NetDev Community Slack for assistance with installation issues and other problems" + url: https://netdev.chat + about: "Join #netbox on the NetDev Community Slack for assistance with installation issues and other problems." diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index fcb3516b4..5df3069ba 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: v3.5.1 + placeholder: v3.5.8 validations: required: true - type: dropdown diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6b71fb515..301fac079 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,12 +14,25 @@
-Some general tips for engaging here on GitHub: +## :information_source: Welcome to the Stadium! + +In her book [Working in Public](https://www.amazon.com/Working-Public-Making-Maintenance-Software/dp/0578675862), Nadia Eghbal defines four production models for open source projects, categorized by contributor and user growth: federations, clubs, toys, and stadiums. The NetBox project fits her definition of a stadium very well: + +> Stadiums are projects with low contributor growth and high user growth. While they may receive casual contributions, their regular contributor base does not grow proportionately to their users. As a result, they tend to be powered by one or a few developers. + +The bulk of NetBox's development is carried out by a handful of core maintainers, with occasional contributions from collaborators in the community. We find the stadium analogy very useful in conveying the roles and obligations of both contributors and users. + +If you're a contributor, actively working on the center stage, you have an obligation to produce quality content that will benefit the project as a whole. Conversely, if you're in the audience consuming the work being produced, you have the option of making requests and suggestions, but must also recognize that contributors are under no obligation to act on them. + +NetBox users are welcome to participate in either role, on stage or in the crowd. We ask only that you acknowledge the role you've chosen and respect the roles of others. + +### General Tips for Working on GitHub * Register for a free [GitHub account](https://github.com/signup) if you haven't already. * You can use [GitHub Markdown](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) for formatting text and adding images. * To help mitigate notification spam, please avoid "bumping" issues with no activity. (To vote an issue up or down, use a :thumbsup: or :thumbsdown: reaction.) * Please avoid pinging members with `@` unless they've previously expressed interest or involvement with that particular issue. +* Familiarize yourself with [this list of discussion anti-patterns](https://github.com/bradfitz/issue-tracker-behaviors) and make every effort to avoid them. ## :bug: Reporting Bugs diff --git a/README.md b/README.md index 480f0f856..54b3e727e 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,10 @@The premiere source of truth powering network automation
+{stacktrace}") logger.error(f"Exception raised during report execution: {e}") job.terminate(status=JobStatusChoices.STATUS_ERRORED) - finally: - job.data = self._results - job.terminate() # Perform any post-run tasks self.post_run() diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index cebc57af4..9fa31db31 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -366,7 +366,7 @@ class BaseScript: if self.fieldsets: fieldsets.extend(self.fieldsets) else: - fields = (name for name, _ in self._get_vars().items()) + fields = list(name for name, _ in self._get_vars().items()) fieldsets.append(('Script Data', fields)) # Append the default fieldset if defined in the Meta class @@ -390,6 +390,11 @@ class BaseScript: # Set initial "commit" checkbox state based on the script's Meta parameter form.fields['_commit'].initial = self.commit_default + # Hide fields if scheduling has been disabled + if not self.scheduling_enabled: + form.fields['_schedule_at'].widget = forms.HiddenInput() + form.fields['_interval'].widget = forms.HiddenInput() + return form # Logging diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index e6d014302..9e4924532 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -22,6 +22,14 @@ __all__ = ( 'WebhookTable', ) +IMAGEATTACHMENT_IMAGE = ''' +{% if record.image %} + {{ record }} +{% else %} + — +{% endif %} +''' + class CustomFieldTable(NetBoxTable): name = tables.Column( @@ -73,6 +81,7 @@ class ExportTemplateTable(NetBoxTable): linkify=True ) is_synced = columns.BooleanColumn( + orderable=False, verbose_name='Synced' ) @@ -95,6 +104,9 @@ class ImageAttachmentTable(NetBoxTable): parent = tables.Column( linkify=True ) + image = tables.TemplateColumn( + template_code=IMAGEATTACHMENT_IMAGE, + ) size = tables.Column( orderable=False, verbose_name='Size (bytes)' @@ -218,6 +230,7 @@ class ConfigContextTable(NetBoxTable): verbose_name='Active' ) is_synced = columns.BooleanColumn( + orderable=False, verbose_name='Synced' ) @@ -242,6 +255,7 @@ class ConfigTemplateTable(NetBoxTable): linkify=True ) is_synced = columns.BooleanColumn( + orderable=False, verbose_name='Synced' ) tags = columns.TagColumn( diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index b59481a36..086c8e246 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -8,7 +8,6 @@ from rest_framework import status from core.choices import ManagedFileRootPathChoices from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site -from extras.api.views import ReportViewSet, ScriptViewSet from extras.models import * from extras.reports import Report from extras.scripts import BooleanVar, IntegerVar, Script, StringVar @@ -579,6 +578,7 @@ class ReportTest(APITestCase): super().setUp() # Monkey-patch the API viewset's _get_report() method to return our test Report above + from extras.api.views import ReportViewSet ReportViewSet._get_report = self.get_test_report def test_get_report(self): @@ -621,6 +621,7 @@ class ScriptTest(APITestCase): super().setUp() # Monkey-patch the API viewset's _get_script() method to return our test Script above + from extras.api.views import ScriptViewSet ScriptViewSet._get_script = self.get_test_script def test_get_script(self): diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 6a3a3d074..3fd0dc83e 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -29,6 +29,17 @@ class CustomFieldTest(TestCase): cls.object_type = ContentType.objects.get_for_model(Site) + def test_invalid_name(self): + """ + Try creating a CustomField with an invalid name. + """ + with self.assertRaises(ValidationError): + # Invalid character + CustomField(name='?', type=CustomFieldTypeChoices.TYPE_TEXT).full_clean() + with self.assertRaises(ValidationError): + # Double underscores not permitted + CustomField(name='foo__bar', type=CustomFieldTypeChoices.TYPE_TEXT).full_clean() + def test_text_field(self): value = 'Foobar!' diff --git a/netbox/extras/tests/test_plugins.py b/netbox/extras/tests/test_plugins.py index cb7629ad2..42dde43fd 100644 --- a/netbox/extras/tests/test_plugins.py +++ b/netbox/extras/tests/test_plugins.py @@ -5,8 +5,9 @@ from django.core.exceptions import ImproperlyConfigured from django.test import Client, TestCase, override_settings from django.urls import reverse -from extras.plugins import PluginMenu, get_plugin_config +from extras.plugins import PluginMenu from extras.tests.dummy_plugin import config as dummy_config +from extras.plugins.utils import get_plugin_config from netbox.graphql.schema import Query from netbox.registry import registry diff --git a/netbox/extras/tests/test_webhooks.py b/netbox/extras/tests/test_webhooks.py index 19264dabb..ef7637765 100644 --- a/netbox/extras/tests/test_webhooks.py +++ b/netbox/extras/tests/test_webhooks.py @@ -31,8 +31,8 @@ class WebhookTest(APITestCase): def setUpTestData(cls): site_ct = ContentType.objects.get_for_model(Site) - DUMMY_URL = "http://localhost/" - DUMMY_SECRET = "LOOKATMEIMASECRETSTRING" + DUMMY_URL = 'http://localhost:9000/' + DUMMY_SECRET = 'LOOKATMEIMASECRETSTRING' webhooks = Webhook.objects.bulk_create(( Webhook(name='Webhook 1', type_create=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers='X-Foo: Bar'), @@ -259,7 +259,7 @@ class WebhookTest(APITestCase): name='Conditional Webhook', type_create=True, type_update=True, - payload_url='http://localhost/', + payload_url='http://localhost:9000/', conditions={ 'and': [ { diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 6cbadf09d..6ba63ab58 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -511,7 +511,7 @@ class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView): # class ObjectChangeListView(generic.ObjectListView): - queryset = ObjectChange.objects.all() + queryset = ObjectChange.objects.valid_models() filterset = filtersets.ObjectChangeFilterSet filterset_form = forms.ObjectChangeFilterForm table = tables.ObjectChangeTable @@ -521,10 +521,10 @@ class ObjectChangeListView(generic.ObjectListView): @register_model_view(ObjectChange) class ObjectChangeView(generic.ObjectView): - queryset = ObjectChange.objects.all() + queryset = ObjectChange.objects.valid_models() def get_extra_context(self, request, instance): - related_changes = ObjectChange.objects.restrict(request.user, 'view').filter( + related_changes = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter( request_id=instance.request_id ).exclude( pk=instance.pk @@ -534,7 +534,7 @@ class ObjectChangeView(generic.ObjectView): orderable=False ) - objectchanges = ObjectChange.objects.restrict(request.user, 'view').filter( + objectchanges = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter( changed_object_type=instance.changed_object_type, changed_object_id=instance.changed_object_id, ) diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index 23702949a..1fc869ee8 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -9,6 +9,7 @@ from netbox.config import get_config from netbox.constants import RQ_QUEUE_DEFAULT from netbox.registry import registry from utilities.api import get_serializer_for_model +from utilities.rqworker import get_rq_retry from utilities.utils import serialize_object from .choices import * from .models import Webhook @@ -116,5 +117,6 @@ def flush_webhooks(queue): snapshots=data['snapshots'], timestamp=str(timezone.now()), username=data['username'], - request_id=data['request_id'] + request_id=data['request_id'], + retry=get_rq_retry() ) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 064452667..1501f16dc 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -218,12 +218,13 @@ class VLANGroupSerializer(NetBoxModelSerializer): scope_id = serializers.IntegerField(allow_null=True, required=False, default=None) scope = serializers.SerializerMethodField(read_only=True) vlan_count = serializers.IntegerField(read_only=True) + utilization = serializers.CharField(read_only=True) class Meta: model = VLANGroup fields = [ 'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid', - 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', + 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization' ] validators = [] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index f432e0e6b..feffc3ff2 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,5 +1,7 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.db import transaction +from django.db.models import F +from django.db.models.functions import Round from django.shortcuts import get_object_or_404 from django_pglocks import advisory_lock from drf_spectacular.utils import extend_schema @@ -145,9 +147,7 @@ class FHRPGroupAssignmentViewSet(NetBoxModelViewSet): class VLANGroupViewSet(NetBoxModelViewSet): - queryset = VLANGroup.objects.annotate( - vlan_count=count_related(VLAN, 'group') - ).prefetch_related('tags') + queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags') serializer_class = serializers.VLANGroupSerializer filterset_class = filtersets.VLANGroupFilterSet @@ -224,7 +224,10 @@ class AvailableASNsView(ObjectValidationMixin, APIView): return Response(serializer.data) - @extend_schema(methods=["post"], responses={201: serializers.ASNSerializer(many=True)}) + @extend_schema(methods=["post"], + responses={201: serializers.ASNSerializer(many=True)}, + request=serializers.ASNSerializer(many=True), + ) @advisory_lock(ADVISORY_LOCK_KEYS['available-asns']) def post(self, request, pk): self.queryset = self.queryset.restrict(request.user, 'add') @@ -293,7 +296,10 @@ class AvailablePrefixesView(ObjectValidationMixin, APIView): return Response(serializer.data) - @extend_schema(methods=["post"], responses={201: serializers.PrefixSerializer(many=True)}) + @extend_schema(methods=["post"], + responses={201: serializers.PrefixSerializer(many=True)}, + request=serializers.PrefixSerializer(many=True), + ) @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes']) def post(self, request, pk): self.queryset = self.queryset.restrict(request.user, 'add') @@ -388,7 +394,10 @@ class AvailableIPAddressesView(ObjectValidationMixin, APIView): return Response(serializer.data) - @extend_schema(methods=["post"], responses={201: serializers.IPAddressSerializer(many=True)}) + @extend_schema(methods=["post"], + responses={201: serializers.IPAddressSerializer(many=True)}, + request=serializers.IPAddressSerializer(many=True), + ) @advisory_lock(ADVISORY_LOCK_KEYS['available-ips']) def post(self, request, pk): self.queryset = self.queryset.restrict(request.user, 'add') @@ -468,7 +477,10 @@ class AvailableVLANsView(ObjectValidationMixin, APIView): return Response(serializer.data) - @extend_schema(methods=["post"], responses={201: serializers.VLANSerializer(many=True)}) + @extend_schema(methods=["post"], + responses={201: serializers.VLANSerializer(many=True)}, + request=serializers.VLANSerializer(many=True), + ) @advisory_lock(ADVISORY_LOCK_KEYS['available-vlans']) def post(self, request, pk): self.queryset = self.queryset.restrict(request.user, 'add') diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index a128b6acc..9b57cb273 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -4,6 +4,8 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db.models import Q from django.utils.translation import gettext as _ +from drf_spectacular.utils import extend_schema_field +from drf_spectacular.types import OpenApiTypes from netaddr.core import AddrFormatError from dcim.models import Device, Interface, Region, Site, SiteGroup @@ -414,6 +416,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): except (AddrFormatError, ValueError): return queryset.none() + @extend_schema_field(OpenApiTypes.STR) def filter_present_in_vrf(self, queryset, name, vrf): if vrf is None: return queryset.none @@ -588,6 +591,10 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): method='_assigned_to_interface', label=_('Is assigned to an interface'), ) + assigned = django_filters.BooleanFilter( + method='_assigned', + label=_('Is assigned'), + ) status = django_filters.MultipleChoiceFilter( choices=IPAddressStatusChoices, null_value=None @@ -659,6 +666,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): return queryset return queryset.filter(address__net_mask_length=value) + @extend_schema_field(OpenApiTypes.STR) def filter_present_in_vrf(self, queryset, name, vrf): if vrf is None: return queryset.none @@ -702,6 +710,18 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): assigned_object_id__isnull=False ) + def _assigned(self, queryset, name, value): + if value: + return queryset.exclude( + assigned_object_type__isnull=True, + assigned_object_id__isnull=True + ) + else: + return queryset.filter( + assigned_object_type__isnull=True, + assigned_object_id__isnull=True + ) + class FHRPGroupFilterSet(NetBoxModelFilterSet): protocol = django_filters.MultipleChoiceFilter( @@ -727,6 +747,7 @@ class FHRPGroupFilterSet(NetBoxModelFilterSet): Q(name__icontains=value) ) + @extend_schema_field(OpenApiTypes.STR) def filter_related_ip(self, queryset, name, value): """ Filter by VRF & prefix of assigned IP addresses. @@ -941,9 +962,11 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): pass return queryset.filter(qs_filter) + @extend_schema_field(OpenApiTypes.STR) def get_for_device(self, queryset, name, value): return queryset.get_for_device(value) + @extend_schema_field(OpenApiTypes.STR) def get_for_virtualmachine(self, queryset, name, value): return queryset.get_for_virtualmachine(value) diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index fd0b315a0..3bce26249 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -9,7 +9,9 @@ from ipam.constants import * from ipam.models import * from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant -from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField +from utilities.forms.fields import ( + CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField +) from virtualization.models import VirtualMachine, VMInterface __all__ = ( @@ -40,10 +42,25 @@ class VRFImportForm(NetBoxModelImportForm): to_field_name='name', help_text=_('Assigned tenant') ) + import_targets = CSVModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + required=False, + to_field_name='name', + help_text=_('Import route targets') + ) + export_targets = CSVModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + required=False, + to_field_name='name', + help_text=_('Export route targets') + ) class Meta: model = VRF - fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description', 'comments', 'tags') + fields = ( + 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'comments', + 'tags', + ) class RouteTargetImportForm(NetBoxModelImportForm): @@ -181,16 +198,31 @@ class PrefixImportForm(NetBoxModelImportForm): def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) - if data: + if not data: + return - # Limit VLAN queryset by assigned site and/or group (if specified) - params = {} - if data.get('site'): - params[f"site__{self.fields['site'].to_field_name}"] = data.get('site') - if data.get('vlan_group'): - params[f"group__{self.fields['vlan_group'].to_field_name}"] = data.get('vlan_group') - if params: - self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params) + site = data.get('site') + vlan_group = data.get('vlan_group') + + # Limit VLAN queryset by assigned site and/or group (if specified) + query = Q() + + if site: + query |= Q(**{ + f"site__{self.fields['site'].to_field_name}": site + }) + # Don't Forget to include VLANs without a site in the filter + query |= Q(**{ + f"site__{self.fields['site'].to_field_name}__isnull": True + }) + + if vlan_group: + query &= Q(**{ + f"group__{self.fields['vlan_group'].to_field_name}": vlan_group + }) + + queryset = self.fields['vlan'].queryset.filter(query) + self.fields['vlan'].queryset = queryset class IPRangeImportForm(NetBoxModelImportForm): diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 53fecfe2f..f00082863 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -253,7 +253,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = IPRange fieldsets = ( (None, ('q', 'filter_id', 'tag')), - ('Attriubtes', ('family', 'vrf_id', 'status', 'role_id', 'mark_utilized')), + ('Attributes', ('family', 'vrf_id', 'status', 'role_id', 'mark_utilized')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) family = forms.ChoiceField( diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index cf8117bf7..a3c218fc9 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -211,10 +211,8 @@ class PrefixForm(TenancyForm, NetBoxModelForm): vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, + selector=True, label=_('VLAN'), - query_params={ - 'site_id': '$site', - } ) role = DynamicModelChoiceField( queryset=Role.objects.all(), @@ -328,6 +326,12 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): ): self.initial['primary_for_parent'] = True + # Disable object assignment fields if the IP address is designated as primary + if self.initial.get('primary_for_parent'): + self.fields['interface'].disabled = True + self.fields['vminterface'].disabled = True + self.fields['fhrpgroup'].disabled = True + def clean(self): super().clean() @@ -340,7 +344,12 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): selected_objects[1]: "An IP address can only be assigned to a single object." }) elif selected_objects: - self.instance.assigned_object = self.cleaned_data[selected_objects[0]] + assigned_object = self.cleaned_data[selected_objects[0]] + if self.instance.pk and self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object: + raise ValidationError( + "Cannot reassign IP address while it is designated as the primary IP for the parent object" + ) + self.instance.assigned_object = assigned_object else: self.instance.assigned_object = None @@ -351,6 +360,18 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): 'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs." ) + # Do not allow assigning a network ID or broadcast address to an interface. + if interface and (address := self.cleaned_data.get('address')): + if address.ip == address.network: + msg = f"{address} is a network ID, which may not be assigned to an interface." + if address.version == 4 and address.prefixlen not in (31, 32): + raise ValidationError(msg) + if address.version == 6 and address.prefixlen not in (127, 128): + raise ValidationError(msg) + if address.version == 4 and address.ip == address.broadcast and address.prefixlen not in (31, 32): + msg = f"{address} is a broadcast address, which may not be assigned to an interface." + raise ValidationError(msg) + def save(self, *args, **kwargs): ipaddress = super().save(*args, **kwargs) @@ -358,6 +379,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): interface = self.instance.assigned_object if type(interface) in (Interface, VMInterface): parent = interface.parent_object + parent.snapshot() if self.cleaned_data['primary_for_parent']: if ipaddress.address.version == 4: parent.primary_ip4 = ipaddress diff --git a/netbox/ipam/graphql/gfk_mixins.py b/netbox/ipam/graphql/gfk_mixins.py index 31742c4a4..01c79690a 100644 --- a/netbox/ipam/graphql/gfk_mixins.py +++ b/netbox/ipam/graphql/gfk_mixins.py @@ -24,11 +24,11 @@ class IPAddressAssignmentType(graphene.Union): @classmethod def resolve_type(cls, instance, info): - if type(instance) == Interface: + if type(instance) is Interface: return InterfaceType - if type(instance) == FHRPGroup: + if type(instance) is FHRPGroup: return FHRPGroupType - if type(instance) == VMInterface: + if type(instance) is VMInterface: return VMInterfaceType @@ -42,11 +42,11 @@ class L2VPNAssignmentType(graphene.Union): @classmethod def resolve_type(cls, instance, info): - if type(instance) == Interface: + if type(instance) is Interface: return InterfaceType - if type(instance) == VLAN: + if type(instance) is VLAN: return VLANType - if type(instance) == VMInterface: + if type(instance) is VMInterface: return VMInterfaceType @@ -59,9 +59,9 @@ class FHRPGroupInterfaceType(graphene.Union): @classmethod def resolve_type(cls, instance, info): - if type(instance) == Interface: + if type(instance) is Interface: return InterfaceType - if type(instance) == VMInterface: + if type(instance) is VMInterface: return VMInterfaceType @@ -79,17 +79,17 @@ class VLANGroupScopeType(graphene.Union): @classmethod def resolve_type(cls, instance, info): - if type(instance) == Cluster: + if type(instance) is Cluster: return ClusterType - if type(instance) == ClusterGroup: + if type(instance) is ClusterGroup: return ClusterGroupType - if type(instance) == Location: + if type(instance) is Location: return LocationType - if type(instance) == Rack: + if type(instance) is Rack: return RackType - if type(instance) == Region: + if type(instance) is Region: return RegionType - if type(instance) == Site: + if type(instance) is Site: return SiteType - if type(instance) == SiteGroup: + if type(instance) is SiteGroup: return SiteGroupType diff --git a/netbox/ipam/models/asns.py b/netbox/ipam/models/asns.py index a07cbb789..6c0b5231b 100644 --- a/netbox/ipam/models/asns.py +++ b/netbox/ipam/models/asns.py @@ -4,6 +4,7 @@ from django.urls import reverse from django.utils.translation import gettext as _ from ipam.fields import ASNField +from ipam.querysets import ASNRangeQuerySet from netbox.models import OrganizationalModel, PrimaryModel __all__ = ( @@ -37,6 +38,8 @@ class ASNRange(OrganizationalModel): null=True ) + objects = ASNRangeQuerySet.as_manager() + class Meta: ordering = ('name',) verbose_name = 'ASN range' diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 015f9220c..00dcf8422 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -406,7 +406,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel): Return all available IPs within this prefix as an IPSet. """ if self.mark_utilized: - return list() + return netaddr.IPSet() prefix = netaddr.IPSet(self.prefix) child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()]) diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 7d4777da9..da504ded2 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -9,7 +9,7 @@ from django.utils.translation import gettext as _ from dcim.models import Interface from ipam.choices import * from ipam.constants import * -from ipam.querysets import VLANQuerySet +from ipam.querysets import VLANQuerySet, VLANGroupQuerySet from netbox.models import OrganizationalModel, PrimaryModel from virtualization.models import VMInterface @@ -63,6 +63,8 @@ class VLANGroup(OrganizationalModel): help_text=_('Highest permissible ID of a child VLAN') ) + objects = VLANGroupQuerySet.as_manager() + class Meta: ordering = ('name', 'pk') # Name may be non-unique constraints = ( diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py index 9f4463f61..39da0c3a2 100644 --- a/netbox/ipam/querysets.py +++ b/netbox/ipam/querysets.py @@ -1,8 +1,34 @@ from django.contrib.contenttypes.models import ContentType -from django.db.models import Q +from django.db.models import Count, F, OuterRef, Q, Subquery, Value from django.db.models.expressions import RawSQL +from django.db.models.functions import Round from utilities.querysets import RestrictedQuerySet +from utilities.utils import count_related + +__all__ = ( + 'ASNRangeQuerySet', + 'PrefixQuerySet', + 'VLANQuerySet', +) + + +class ASNRangeQuerySet(RestrictedQuerySet): + + def annotate_asn_counts(self): + """ + Annotate the number of ASNs which appear within each range. + """ + from .models import ASN + + # Because ASN does not have a foreign key to ASNRange, we create a fake column "_" with a consistent value + # that we can use to count ASNs and return a single value per ASNRange. + asns = ASN.objects.filter( + asn__gte=OuterRef('start'), + asn__lte=OuterRef('end') + ).order_by().annotate(_=Value(1)).values('_').annotate(c=Count('*')).values('c') + + return self.annotate(asn_count=Subquery(asns)) class PrefixQuerySet(RestrictedQuerySet): @@ -30,6 +56,17 @@ class PrefixQuerySet(RestrictedQuerySet): ) +class VLANGroupQuerySet(RestrictedQuerySet): + + def annotate_utilization(self): + from .models import VLAN + + return self.annotate( + vlan_count=count_related(VLAN, 'group'), + utilization=Round(F('vlan_count') / (F('max_vid') - F('min_vid') + 1.0) * 100, 2) + ) + + class VLANQuerySet(RestrictedQuerySet): def get_for_device(self, device): diff --git a/netbox/ipam/tables/asn.py b/netbox/ipam/tables/asn.py index 511e914ec..356f2fc17 100644 --- a/netbox/ipam/tables/asn.py +++ b/netbox/ipam/tables/asn.py @@ -21,10 +21,8 @@ class ASNRangeTable(TenancyColumnsMixin, NetBoxTable): tags = columns.TagColumn( url_name='ipam:asnrange_list' ) - asn_count = columns.LinkedCountColumn( - viewname='ipam:asn_list', - url_params={'asn_id': 'pk'}, - verbose_name=_('ASN Count') + asn_count = tables.Column( + verbose_name=_('ASNs') ) class Meta(NetBoxTable.Meta): @@ -59,7 +57,8 @@ class ASNTable(TenancyColumnsMixin, NetBoxTable): verbose_name=_('Provider Count') ) sites = columns.ManyToManyColumn( - linkify_item=True + linkify_item=True, + verbose_name=_('Sites') ) comments = columns.MarkdownColumn() tags = columns.TagColumn( diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 86d1a3775..aff090f3a 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -19,14 +19,22 @@ __all__ = ( AVAILABLE_LABEL = mark_safe('Available') +AGGREGATE_COPY_BUTTON = """ +{% copy_content record.pk prefix="aggregate_" %} +""" + PREFIX_LINK = """ {% if record.pk %} - {{ record.prefix }} + {{ record.prefix }} {% else %} {{ record.prefix }} {% endif %} """ +PREFIX_COPY_BUTTON = """ +{% copy_content record.pk prefix="prefix_" %} +""" + PREFIX_LINK_WITH_DEPTH = """ {% load helpers %} {% if record.depth %} @@ -40,7 +48,7 @@ PREFIX_LINK_WITH_DEPTH = """ IPADDRESS_LINK = """ {% if record.pk %} - {{ record.address }} + {{ record.address }} {% elif perms.ipam.add_ipaddress %} {% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available {% else %} @@ -48,6 +56,10 @@ IPADDRESS_LINK = """ {% endif %} """ +IPADDRESS_COPY_BUTTON = """ +{% copy_content record.pk prefix="ipaddress_" %} +""" + IPADDRESS_ASSIGN_LINK = """ {{ record }} """ @@ -99,7 +111,11 @@ class RIRTable(NetBoxTable): class AggregateTable(TenancyColumnsMixin, NetBoxTable): prefix = tables.Column( linkify=True, - verbose_name='Aggregate' + verbose_name='Aggregate', + attrs={ + # Allow the aggregate to be copied to the clipboard + 'a': {'id': lambda record: f"aggregate_{record.pk}"} + } ) date_added = tables.DateColumn( format="Y-m-d", @@ -116,6 +132,9 @@ class AggregateTable(TenancyColumnsMixin, NetBoxTable): tags = columns.TagColumn( url_name='ipam:aggregate_list' ) + actions = columns.ActionsColumn( + extra_buttons=AGGREGATE_COPY_BUTTON + ) class Meta(NetBoxTable.Meta): model = Aggregate @@ -242,6 +261,9 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable): tags = columns.TagColumn( url_name='ipam:prefix_list' ) + actions = columns.ActionsColumn( + extra_buttons=PREFIX_COPY_BUTTON + ) class Meta(NetBoxTable.Meta): model = Prefix @@ -348,6 +370,9 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable): tags = columns.TagColumn( url_name='ipam:ipaddress_list' ) + actions = columns.ActionsColumn( + extra_buttons=IPADDRESS_COPY_BUTTON + ) class Meta(NetBoxTable.Meta): model = IPAddress diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index 6fa2cd2da..5d9828531 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -70,6 +70,10 @@ class VLANGroupTable(NetBoxTable): url_params={'group_id': 'pk'}, verbose_name='VLANs' ) + utilization = columns.UtilizationColumn( + orderable=False, + verbose_name='Utilization' + ) tags = columns.TagColumn( url_name='ipam:vlangroup_list' ) @@ -81,9 +85,9 @@ class VLANGroupTable(NetBoxTable): model = VLANGroup fields = ( 'pk', 'id', 'name', 'scope_type', 'scope', 'min_vid', 'max_vid', 'vlan_count', 'slug', 'description', - 'tags', 'created', 'last_updated', 'actions', + 'tags', 'created', 'last_updated', 'actions', 'utilization', ) - default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description') + default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'utilization', 'description') # diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 3d9a66567..0ae7544ab 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -992,6 +992,12 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'fhrpgroup_id': [fhrp_groups[0].pk, fhrp_groups[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_assigned(self): + params = {'assigned': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + params = {'assigned': 'false'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_assigned_to_interface(self): params = {'assigned_to_interface': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 44af9eae2..c9128c0f6 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -495,6 +495,65 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk}) self.assertHttpStatus(self.client.get(url), 200) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_prefix_import(self): + """ + Custom import test for YAML-based imports (versus CSV) + """ + IMPORT_DATA = """ +prefix: 10.1.1.0/24 +status: active +vlan: 101 +site: Site 1 +""" + # Note, a site is not tied to the VLAN to verify the fix for #12622 + VLAN.objects.create(vid=101, name='VLAN101') + + # Add all required permissions to the test user + self.add_permissions('ipam.view_prefix', 'ipam.add_prefix') + + form_data = { + 'data': IMPORT_DATA, + 'format': 'yaml' + } + response = self.client.post(reverse('ipam:prefix_import'), data=form_data, follow=True) + self.assertHttpStatus(response, 200) + + prefix = Prefix.objects.get(prefix='10.1.1.0/24') + self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE) + self.assertEqual(prefix.vlan.vid, 101) + self.assertEqual(prefix.site.name, "Site 1") + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_prefix_import_with_vlan_group(self): + """ + This test covers a unique import edge case where VLAN group is specified during the import. + """ + IMPORT_DATA = """ +prefix: 10.1.2.0/24 +status: active +vlan: 102 +site: Site 1 +vlan_group: Group 1 +""" + vlan_group = VLANGroup.objects.create(name='Group 1', slug='group-1', scope=Site.objects.get(name="Site 1")) + VLAN.objects.create(vid=102, name='VLAN102', group=vlan_group) + + # Add all required permissions to the test user + self.add_permissions('ipam.view_prefix', 'ipam.add_prefix') + + form_data = { + 'data': IMPORT_DATA, + 'format': 'yaml' + } + response = self.client.post(reverse('ipam:prefix_import'), data=form_data, follow=True) + self.assertHttpStatus(response, 200) + + prefix = Prefix.objects.get(prefix='10.1.2.0/24') + self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE) + self.assertEqual(prefix.vlan.vid, 102) + self.assertEqual(prefix.site.name, "Site 1") + class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = IPRange diff --git a/netbox/ipam/utils.py b/netbox/ipam/utils.py index 93a40e5a0..262fd8d46 100644 --- a/netbox/ipam/utils.py +++ b/netbox/ipam/utils.py @@ -121,7 +121,7 @@ def add_available_vlans(vlans, vlan_group=None): }) vlans = list(vlans) + new_vlans - vlans.sort(key=lambda v: v.vid if type(v) == VLAN else v['vid']) + vlans.sort(key=lambda v: v.vid if type(v) is VLAN else v['vid']) return vlans diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 6b19b502d..d8e4d8b47 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1,6 +1,7 @@ from django.contrib.contenttypes.models import ContentType -from django.db.models import Prefetch +from django.db.models import F, Prefetch from django.db.models.expressions import RawSQL +from django.db.models.functions import Round from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.translation import gettext as _ @@ -9,6 +10,7 @@ from circuits.models import Provider from dcim.filtersets import InterfaceFilterSet from dcim.models import Interface, Site from netbox.views import generic +from tenancy.views import ObjectContactsView from utilities.utils import count_related from utilities.views import ViewTab, register_model_view from virtualization.filtersets import VMInterfaceFilterSet @@ -197,7 +199,7 @@ class RIRBulkDeleteView(generic.BulkDeleteView): # class ASNRangeListView(generic.ObjectListView): - queryset = ASNRange.objects.all() + queryset = ASNRange.objects.annotate_asn_counts() filterset = filtersets.ASNRangeFilterSet filterset_form = forms.ASNRangeFilterForm table = tables.ASNRangeTable @@ -214,7 +216,7 @@ class ASNRangeASNsView(generic.ObjectChildrenView): child_model = ASN table = tables.ASNTable filterset = filtersets.ASNFilterSet - template_name = 'ipam/asnrange/asns.html' + template_name = 'generic/object_children.html' tab = ViewTab( label=_('ASNs'), badge=lambda x: x.get_child_asns().count(), @@ -246,18 +248,14 @@ class ASNRangeBulkImportView(generic.BulkImportView): class ASNRangeBulkEditView(generic.BulkEditView): - queryset = ASNRange.objects.annotate( - site_count=count_related(Site, 'asns') - ) + queryset = ASNRange.objects.annotate_asn_counts() filterset = filtersets.ASNRangeFilterSet table = tables.ASNRangeTable form = forms.ASNRangeBulkEditForm class ASNRangeBulkDeleteView(generic.BulkDeleteView): - queryset = ASNRange.objects.annotate( - site_count=count_related(Site, 'asns') - ) + queryset = ASNRange.objects.annotate_asn_counts() filterset = filtersets.ASNRangeFilterSet table = tables.ASNRangeTable @@ -818,7 +816,6 @@ class IPAddressAssignView(generic.ObjectView): table = None if form.is_valid(): - addresses = self.queryset.prefetch_related('vrf', 'tenant') # Limit to 100 results addresses = filtersets.IPAddressFilterSet(request.POST, addresses).qs[:100] @@ -868,7 +865,7 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView): child_model = IPAddress table = tables.IPAddressTable filterset = filtersets.IPAddressFilterSet - template_name = 'ipam/ipaddress/ip_addresses.html' + template_name = 'generic/object_children.html' tab = ViewTab( label=_('Related IPs'), badge=lambda x: x.get_related_ips().count(), @@ -885,9 +882,7 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView): # class VLANGroupListView(generic.ObjectListView): - queryset = VLANGroup.objects.annotate( - vlan_count=count_related(VLAN, 'group') - ) + queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags') filterset = filtersets.VLANGroupFilterSet filterset_form = forms.VLANGroupFilterForm table = tables.VLANGroupTable @@ -895,7 +890,7 @@ class VLANGroupListView(generic.ObjectListView): @register_model_view(VLANGroup) class VLANGroupView(generic.ObjectView): - queryset = VLANGroup.objects.all() + queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags') def get_extra_context(self, request, instance): related_models = ( @@ -937,18 +932,14 @@ class VLANGroupBulkImportView(generic.BulkImportView): class VLANGroupBulkEditView(generic.BulkEditView): - queryset = VLANGroup.objects.annotate( - vlan_count=count_related(VLAN, 'group') - ) + queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags') filterset = filtersets.VLANGroupFilterSet table = tables.VLANGroupTable form = forms.VLANGroupBulkEditForm class VLANGroupBulkDeleteView(generic.BulkDeleteView): - queryset = VLANGroup.objects.annotate( - vlan_count=count_related(VLAN, 'group') - ) + queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags') filterset = filtersets.VLANGroupFilterSet table = tables.VLANGroupTable @@ -971,7 +962,6 @@ class FHRPGroupView(generic.ObjectView): queryset = FHRPGroup.objects.all() def get_extra_context(self, request, instance): - # Get assigned interfaces members_table = tables.FHRPGroupAssignmentTable( data=FHRPGroupAssignment.objects.restrict(request.user, 'view').filter(group=instance), @@ -1085,7 +1075,7 @@ class VLANInterfacesView(generic.ObjectChildrenView): child_model = Interface table = tables.VLANDevicesTable filterset = InterfaceFilterSet - template_name = 'ipam/vlan/interfaces.html' + template_name = 'generic/object_children.html' tab = ViewTab( label=_('Device Interfaces'), badge=lambda x: x.get_interfaces().count(), @@ -1103,7 +1093,7 @@ class VLANVMInterfacesView(generic.ObjectChildrenView): child_model = VMInterface table = tables.VLANVirtualMachinesTable filterset = VMInterfaceFilterSet - template_name = 'ipam/vlan/vminterfaces.html' + template_name = 'generic/object_children.html' tab = ViewTab( label=_('VM Interfaces'), badge=lambda x: x.get_vminterfaces().count(), @@ -1300,6 +1290,11 @@ class L2VPNBulkDeleteView(generic.BulkDeleteView): table = tables.L2VPNTable +@register_model_view(L2VPN, 'contacts') +class L2VPNContactsView(ObjectContactsView): + queryset = L2VPN.objects.all() + + # # L2VPN terminations # diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index 814ca1ed6..f0bd5fd27 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -60,7 +60,7 @@ class TokenAuthentication(authentication.TokenAuthentication): user = token.user # When LDAP authentication is active try to load user data from LDAP directory - if settings.REMOTE_AUTH_BACKEND == 'netbox.authentication.LDAPBackend': + if 'netbox.authentication.LDAPBackend' in settings.REMOTE_AUTH_BACKEND: from netbox.authentication import LDAPBackend ldap_backend = LDAPBackend() diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py index 5c55697ff..97f690762 100644 --- a/netbox/netbox/api/views.py +++ b/netbox/netbox/api/views.py @@ -11,6 +11,7 @@ from rest_framework.reverse import reverse from rest_framework.views import APIView from rq.worker import Worker +from extras.plugins.utils import get_installed_plugins from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired @@ -61,19 +62,11 @@ class StatusView(APIView): installed_apps[app_config.name] = version installed_apps = {k: v for k, v in sorted(installed_apps.items())} - # Gather installed plugins - plugins = {} - for plugin_name in settings.PLUGINS: - plugin_name = plugin_name.rsplit('.', 1)[-1] - plugin_config = apps.get_app_config(plugin_name) - plugins[plugin_name] = getattr(plugin_config, 'version', None) - plugins = {k: v for k, v in sorted(plugins.items())} - return Response({ 'django-version': DJANGO_VERSION, 'installed-apps': installed_apps, 'netbox-version': settings.VERSION, - 'plugins': plugins, + 'plugins': get_installed_plugins(), 'python-version': platform.python_version(), 'rq-workers-running': Worker.count(get_connection('default')), }) diff --git a/netbox/netbox/api/viewsets/mixins.py b/netbox/netbox/api/viewsets/mixins.py index 8b629bbc6..fde486fe9 100644 --- a/netbox/netbox/api/viewsets/mixins.py +++ b/netbox/netbox/api/viewsets/mixins.py @@ -15,11 +15,12 @@ from utilities.api import get_serializer_for_model __all__ = ( 'BriefModeMixin', + 'BulkDestroyModelMixin', 'BulkUpdateModelMixin', 'CustomFieldsMixin', 'ExportTemplatesMixin', - 'BulkDestroyModelMixin', 'ObjectValidationMixin', + 'SequentialBulkCreatesMixin', ) @@ -94,6 +95,30 @@ class ExportTemplatesMixin: return super().list(request, *args, **kwargs) +class SequentialBulkCreatesMixin: + """ + Perform bulk creation of new objects sequentially, rather than all at once. This ensures that any validation + which depends on the evaluation of existing objects (such as checking for free space within a rack) functions + appropriately. + """ + @transaction.atomic + def create(self, request, *args, **kwargs): + if not isinstance(request.data, list): + # Creating a single object + return super().create(request, *args, **kwargs) + + return_data = [] + for data in request.data: + serializer = self.get_serializer(data=data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + return_data.append(serializer.data) + + headers = self.get_success_headers(serializer.data) + + return Response(return_data, status=status.HTTP_201_CREATED, headers=headers) + + class BulkUpdateModelMixin: """ Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index 798cb80e2..61dfe2fdb 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -156,8 +156,11 @@ class RemoteUserBackend(_RemoteUserBackend): try: group_list.append(Group.objects.get(name=name)) except Group.DoesNotExist: - logging.error( - f"Could not assign group {name} to remotely-authenticated user {user}: Group not found") + if settings.REMOTE_AUTH_AUTO_CREATE_GROUPS: + group_list.append(Group.objects.create(name=name)) + else: + logging.error( + f"Could not assign group {name} to remotely-authenticated user {user}: Group not found") if group_list: user.groups.set(group_list) logger.debug( diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py index 2bfa234f0..9c613217c 100644 --- a/netbox/netbox/config/parameters.py +++ b/netbox/netbox/config/parameters.py @@ -28,6 +28,17 @@ PARAMS = ( ), }, ), + ConfigParam( + name='BANNER_MAINTENANCE', + label=_('Maintenance banner'), + default='NetBox is currently in maintenance mode. Functionality may be limited.', + description=_('Additional content to display when in maintenance mode'), + field_kwargs={ + 'widget': forms.Textarea( + attrs={'class': 'vLargeTextField'} + ), + }, + ), ConfigParam( name='BANNER_TOP', label=_('Top banner'), diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index a0c1edee8..9a2385c45 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -177,7 +177,8 @@ class BaseFilterSet(django_filters.FilterSet): # create the new filter with the same type because there is no guarantee the defined type # is the same as the default type for the field resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid - new_filter = type(existing_filter)( + filter_cls = django_filters.BooleanFilter if lookup_expr == 'empty' else type(existing_filter) + new_filter = filter_cls( field_name=field_name, lookup_expr=lookup_expr, label=existing_filter.label, @@ -224,6 +225,14 @@ class BaseFilterSet(django_filters.FilterSet): return filters + @classmethod + def filter_for_lookup(cls, field, lookup_type): + + if lookup_type == 'empty': + return django_filters.BooleanFilter, {} + + return super().filter_for_lookup(field, lookup_type) + class ChangeLoggedModelFilterSet(BaseFilterSet): """ diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 83c238e0f..b406ab04e 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -78,7 +78,10 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm): def _get_custom_fields(self, content_type): return CustomField.objects.filter(content_types=content_type).filter( - ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE + ui_visibility__in=[ + CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE, + CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET, + ] ) def _get_form_field(self, customfield): diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py index f9faa9c5d..18f350fd7 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -3,19 +3,21 @@ import uuid from urllib import parse from django.conf import settings -from django.contrib import auth +from django.contrib import auth, messages from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_ from django.core.exceptions import ImproperlyConfigured -from django.db import ProgrammingError +from django.db import connection, ProgrammingError +from django.db.utils import InternalError from django.http import Http404, HttpResponseRedirect from extras.context_managers import change_logging -from netbox.config import clear_config +from netbox.config import clear_config, get_config from netbox.views import handler_500 from utilities.api import is_api_request, rest_api_server_error __all__ = ( 'CoreMiddleware', + 'MaintenanceModeMiddleware', 'RemoteUserMiddleware', ) @@ -47,6 +49,9 @@ class CoreMiddleware: # Attach the unique request ID as an HTTP header. response['X-Request-ID'] = request.id + # Enable the Vary header to help with caching of HTMX responses + response['Vary'] = 'HX-Request' + # If this is an API request, attach an HTTP header annotating the API version (e.g. '3.5'). if is_api_request(request): response['API-Version'] = settings.REST_FRAMEWORK_VERSION @@ -166,3 +171,47 @@ class RemoteUserMiddleware(RemoteUserMiddleware_): groups = [] logger.debug(f"Groups are {groups}") return groups + + +class MaintenanceModeMiddleware: + """ + Middleware that checks if the application is in maintenance mode + and restricts write-related operations to the database. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if get_config().MAINTENANCE_MODE: + self._set_session_type( + allow_write=request.path_info.startswith(settings.MAINTENANCE_EXEMPT_PATHS) + ) + + return self.get_response(request) + + @staticmethod + def _set_session_type(allow_write): + """ + Prevent any write-related database operations. + + Args: + allow_write (bool): If True, write operations will be permitted. + """ + with connection.cursor() as cursor: + mode = 'READ WRITE' if allow_write else 'READ ONLY' + cursor.execute(f'SET SESSION CHARACTERISTICS AS TRANSACTION {mode};') + + def process_exception(self, request, exception): + """ + Prevent any write-related database operations if an exception is raised. + """ + if get_config().MAINTENANCE_MODE and isinstance(exception, InternalError): + error_message = 'NetBox is currently operating in maintenance mode and is unable to perform write ' \ + 'operations. Please try again later.' + + if is_api_request(request): + return rest_api_server_error(request, error=error_message) + + messages.error(request, error_message) + return HttpResponseRedirect(request.path_info) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 6d82e2a2b..1e55ec2a3 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -197,11 +197,15 @@ class CustomFieldsMixin(models.Model): data = {} for field in CustomField.objects.get_for_model(self): - # Skip fields that are hidden if 'omit_hidden' is set - if omit_hidden and field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN: - continue - value = self.custom_field_data.get(field.name) + + # Skip fields that are hidden if 'omit_hidden' is set + if omit_hidden: + if field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN: + continue + if field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET and not value: + continue + data[field] = field.deserialize(value) return data @@ -227,6 +231,8 @@ class CustomFieldsMixin(models.Model): for cf in visible_custom_fields: value = self.custom_field_data.get(cf.name) + if value in (None, []) and cf.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET: + continue value = cf.deserialize(value) groups[cf.group_name][cf] = value @@ -436,6 +442,19 @@ class SyncedDataMixin(models.Model): return ret + def delete(self, *args, **kwargs): + from core.models import AutoSyncRecord + + # Delete AutoSyncRecord + content_type = ContentType.objects.get_for_model(self) + AutoSyncRecord.objects.filter( + datafile=self.data_file, + object_type=content_type, + object_id=self.pk + ).delete() + + return super().delete(*args, **kwargs) + def resolve_data_file(self): """ Determine the designated DataFile object identified by its parent DataSource and its path. Returns None if diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 6e5bcfc23..1379beba5 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -46,7 +46,7 @@ ORGANIZATION_MENU = Menu( get_model_item('tenancy', 'contact', _('Contacts')), get_model_item('tenancy', 'contactgroup', _('Contact Groups')), get_model_item('tenancy', 'contactrole', _('Contact Roles')), - get_model_item('tenancy', 'contactassignment', _('Contact Assignments'), actions=[]), + get_model_item('tenancy', 'contactassignment', _('Contact Assignments'), actions=['import']), ), ), ), @@ -102,7 +102,7 @@ CONNECTIONS_MENU = Menu( label=_('Connections'), items=( get_model_item('dcim', 'cable', _('Cables'), actions=['import']), - get_model_item('wireless', 'wirelesslink', _('Wireless Links'), actions=['import']), + get_model_item('wireless', 'wirelesslink', _('Wireless Links')), MenuItem( link='dcim:interface_connections_list', link_text=_('Interface Connections'), @@ -301,12 +301,14 @@ CUSTOMIZATION_MENU = Menu( MenuItem( link='extras:report_list', link_text=_('Reports'), - permissions=['extras.view_report'] + permissions=['extras.view_report'], + buttons=get_model_buttons('extras', "reportmodule", actions=['add']) ), MenuItem( link='extras:script_list', link_text=_('Scripts'), - permissions=['extras.view_script'] + permissions=['extras.view_script'], + buttons=get_model_buttons('extras', "scriptmodule", actions=['add']) ), ), ), diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 3f3f96736..aace6745a 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW # Environment setup # -VERSION = '3.5.2-dev' +VERSION = '3.5.9-dev' # Hostname HOSTNAME = platform.node() @@ -122,6 +122,7 @@ PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {}) QUEUE_MAPPINGS = getattr(configuration, 'QUEUE_MAPPINGS', {}) RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None) REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False) +REMOTE_AUTH_AUTO_CREATE_GROUPS = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_GROUPS', False) REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend') REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', []) REMOTE_AUTH_DEFAULT_PERMISSIONS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_PERMISSIONS', {}) @@ -139,6 +140,8 @@ REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', []) REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|') REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300) +RQ_RETRY_INTERVAL = getattr(configuration, 'RQ_RETRY_INTERVAL', 60) +RQ_RETRY_MAX = getattr(configuration, 'RQ_RETRY_MAX', 0) SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/') SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend') SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False) @@ -382,6 +385,7 @@ MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'netbox.middleware.RemoteUserMiddleware', 'netbox.middleware.CoreMiddleware', + 'netbox.middleware.MaintenanceModeMiddleware', 'django_prometheus.middleware.PrometheusAfterMiddleware', ] @@ -457,8 +461,6 @@ LOGIN_REDIRECT_URL = f'/{BASE_PATH}' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' -TEST_RUNNER = "django_rich.test.RichRunner" - # Exclude potentially sensitive models from wildcard view exemption. These may still be exempted # by specifying the model individually in the EXEMPT_VIEW_PERMISSIONS configuration parameter. EXEMPT_EXCLUDE_MODELS = ( @@ -476,6 +478,11 @@ AUTH_EXEMPT_PATHS = ( f'/{BASE_PATH}metrics', ) +# All URLs starting with a string listed here are exempt from maintenance mode enforcement +MAINTENANCE_EXEMPT_PATHS = ( + f'/{BASE_PATH}admin/', +) + SERIALIZATION_MODULES = { 'json': 'utilities.serializers.json', } diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index 66ee787a8..399b3c184 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -234,8 +234,12 @@ class ActionsColumn(tables.Column): return '' model = table.Meta.model - request = getattr(table, 'context', {}).get('request') - url_appendix = f'?return_url={quote(request.get_full_path())}' if request else '' + if request := getattr(table, 'context', {}).get('request'): + return_url = request.GET.get('return_url', request.get_full_path()) + url_appendix = f'?return_url={quote(return_url)}' + else: + url_appendix = '' + html = '' # Compile actions menu @@ -500,9 +504,9 @@ class CustomLinkColumn(tables.Column): """ def __init__(self, customlink, *args, **kwargs): self.customlink = customlink - kwargs['accessor'] = Accessor('pk') - if 'verbose_name' not in kwargs: - kwargs['verbose_name'] = customlink.name + kwargs.setdefault('accessor', Accessor('pk')) + kwargs.setdefault('orderable', False) + kwargs.setdefault('verbose_name', customlink.name) super().__init__(*args, **kwargs) diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 839d85996..975311e4a 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -54,7 +54,7 @@ class BaseTable(tables.Table): # 3. Meta.fields selected_columns = None if user is not None and not isinstance(user, AnonymousUser): - selected_columns = user.config.get(f"tables.{self.__class__.__name__}.columns") + selected_columns = user.config.get(f"tables.{self.name}.columns") if not selected_columns: selected_columns = getattr(self.Meta, 'default_columns', self.Meta.fields) @@ -113,6 +113,10 @@ class BaseTable(tables.Table): columns.append((name, column.verbose_name)) return columns + @property + def name(self): + return self.__class__.__name__ + @property def available_columns(self): return self._get_columns(visible=False) @@ -138,13 +142,16 @@ class BaseTable(tables.Table): """ # Save ordering preference if request.user.is_authenticated: - table_name = self.__class__.__name__ if self.prefixed_order_by_field in request.GET: - # If an ordering has been specified as a query parameter, save it as the - # user's preferred ordering for this table. - ordering = request.GET.getlist(self.prefixed_order_by_field) - request.user.config.set(f'tables.{table_name}.ordering', ordering, commit=True) - elif ordering := request.user.config.get(f'tables.{table_name}.ordering'): + if request.GET[self.prefixed_order_by_field]: + # If an ordering has been specified as a query parameter, save it as the + # user's preferred ordering for this table. + ordering = request.GET.getlist(self.prefixed_order_by_field) + request.user.config.set(f'tables.{self.name}.ordering', ordering, commit=True) + else: + # If the ordering has been set to none (empty), clear any existing preference. + request.user.config.clear(f'tables.{self.name}.ordering', commit=True) + elif ordering := request.user.config.get(f'tables.{self.name}.ordering'): # If no ordering has been specified, set the preferred ordering (if any). self.order_by = ordering diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 790cb4bd8..4e46996b5 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -310,6 +310,50 @@ class ExternalAuthenticationTestCase(TestCase): list(new_user.groups.all()) ) + @override_settings( + REMOTE_AUTH_ENABLED=True, + REMOTE_AUTH_AUTO_CREATE_USER=True, + REMOTE_AUTH_GROUP_SYNC_ENABLED=True, + REMOTE_AUTH_AUTO_CREATE_GROUPS=True, + LOGIN_REQUIRED=True, + ) + def test_remote_auth_remote_groups_autocreate(self): + """ + Test enabling remote authentication with group sync and autocreate + enabled with the default configuration. + """ + headers = { + "HTTP_REMOTE_USER": "remoteuser2", + "HTTP_REMOTE_USER_GROUP": "Group 1|Group 2", + } + + self.assertTrue(settings.REMOTE_AUTH_ENABLED) + self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER) + self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_GROUPS) + self.assertTrue(settings.REMOTE_AUTH_GROUP_SYNC_ENABLED) + self.assertEqual(settings.REMOTE_AUTH_HEADER, "HTTP_REMOTE_USER") + self.assertEqual(settings.REMOTE_AUTH_GROUP_HEADER, "HTTP_REMOTE_USER_GROUP") + self.assertEqual(settings.REMOTE_AUTH_GROUP_SEPARATOR, "|") + + groups = ( + Group(name="Group 1"), + Group(name="Group 2"), + ) + + response = self.client.get(reverse("home"), follow=True, **headers) + self.assertEqual(response.status_code, 200) + + new_user = User.objects.get(username="remoteuser2") + self.assertEqual( + int(self.client.session.get("_auth_user_id")), + new_user.pk, + msg="Authentication failed", + ) + self.assertListEqual( + [group.name for group in groups], + [group.name for group in list(new_user.groups.all())], + ) + @override_settings( REMOTE_AUTH_ENABLED=True, REMOTE_AUTH_AUTO_CREATE_USER=True, diff --git a/netbox/netbox/views/errors.py b/netbox/netbox/views/errors.py index c74c67cef..a81d45cb5 100644 --- a/netbox/netbox/views/errors.py +++ b/netbox/netbox/views/errors.py @@ -11,6 +11,8 @@ from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found from django.views.generic import View from sentry_sdk import capture_message +from extras.plugins.utils import get_installed_plugins + __all__ = ( 'handler_404', 'handler_500', @@ -53,4 +55,5 @@ def handler_500(request, template_name=ERROR_500_TEMPLATE_NAME): 'exception': str(type_), 'netbox_version': settings.VERSION, 'python_version': platform.python_version(), + 'plugins': get_installed_plugins(), })) diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index e66e79a7a..35caa31b3 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -551,7 +551,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): for name, m2m_field in m2m_fields.items(): if name in form.nullable_fields and name in nullified_fields: getattr(obj, name).clear() - else: + elif form.cleaned_data[name]: getattr(obj, name).set(form.cleaned_data[name]) # Add/remove tags diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 1ba789cf1..99d8ff540 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -143,9 +143,12 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin): return render(request, self.get_template_name(), { 'object': instance, 'child_model': self.child_model, + 'base_template': f'{instance._meta.app_label}/{instance._meta.model_name}.html', 'table': table, + 'table_config': f'{table.name}_config', 'actions': actions, 'tab': self.tab, + 'return_url': request.get_full_path(), **self.get_extra_context(request, instance), }) diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css index 11110069e..2aa24b72c 100644 Binary files a/netbox/project-static/dist/netbox-dark.css and b/netbox/project-static/dist/netbox-dark.css differ diff --git a/netbox/project-static/dist/netbox-light.css b/netbox/project-static/dist/netbox-light.css index 8a3c83af9..ffdd83285 100644 Binary files a/netbox/project-static/dist/netbox-light.css and b/netbox/project-static/dist/netbox-light.css differ diff --git a/netbox/project-static/dist/netbox-print.css b/netbox/project-static/dist/netbox-print.css index ef2682e0a..b492e4d1d 100644 Binary files a/netbox/project-static/dist/netbox-print.css and b/netbox/project-static/dist/netbox-print.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 8caaaa9a0..84bfecae3 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 0201e7bf8..7f2400ed2 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 f10b5b7ac..98e1a5c60 100644 --- a/netbox/project-static/package.json +++ b/netbox/project-static/package.json @@ -30,6 +30,7 @@ "dayjs": "^1.11.5", "flatpickr": "4.6.13", "gridstack": "^7.2.3", + "html-entities": "^2.3.3", "htmx.org": "^1.8.0", "just-debounce-it": "^3.1.1", "query-string": "^7.1.1", diff --git a/netbox/project-static/src/buttons/selectAll.ts b/netbox/project-static/src/buttons/selectAll.ts index 64b98d390..f40520e26 100644 --- a/netbox/project-static/src/buttons/selectAll.ts +++ b/netbox/project-static/src/buttons/selectAll.ts @@ -1,4 +1,4 @@ -import { getElement, getElements, findFirstAdjacent } from '../util'; +import { getElements, findFirstAdjacent } from '../util'; /** * If any PK checkbox is checked, uncheck the select all table checkbox and the select all @@ -63,29 +63,6 @@ function handleSelectAllToggle(event: Event): void { } } -/** - * Synchronize the select all confirmation checkbox state with the select all confirmation button - * disabled state. If the select all confirmation checkbox is checked, the buttons should be - * enabled. If not, the buttons should be disabled. - * - * @param event Change Event - */ -function handleSelectAll(event: Event): void { - const target = event.currentTarget as HTMLInputElement; - const selectAllBox = getElement
If further assistance is required, please post to the NetBox discussion forum on GitHub.
diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index 6b247d81a..9e8b7d7bf 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -77,10 +77,10 @@ Blocks: {% endif %} - {% if config.MAINTENANCE_MODE %} + {% if config.MAINTENANCE_MODE and config.BANNER_MAINTENANCE %}{{ context_data|pprint }}+
{{ context_data|pprint }}+
MAC Address | -{{ object.mac_address|placeholder }} | +{{ object.mac_address|placeholder }} | |||||||||||||||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
WWN | -{{ object.wwn|placeholder }} | +{{ object.wwn|placeholder }} | |||||||||||||||||||||||||||||||||||||||||||||
VRF | diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index 743622ee6..dae6d78ff 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -65,7 +65,6 @@
Location | +Racks | +Devices | ++ |
---|---|---|---|
+ {% for i in location.level|as_range %}{% endfor %} + {{ location|linkify }} + | ++ {{ location.rack_count }} + | ++ {{ location.device_count }} + | + +
Name | -Description | -Last Run | -Status | -- | |||||||
---|---|---|---|---|---|---|---|---|---|---|---|
- {{ report.name }} - | -{{ report.description|markdown|placeholder }} | - {% if last_job %} -- {{ last_job.created|annotated_date }} - | -- {% badge last_job.get_status_display last_job.get_status_color %} - | - {% else %} -Never | -{{ ''|placeholder }} | - {% endif %} -- {% if perms.extras.run_report %} - | - - {% endif %} -
Name | +Description | +Last Run | +Status | ++ | |||||||
---|---|---|---|---|---|---|---|---|---|---|---|
- {{ method }} + | + {{ report.name }} | -- {{ stats.success }} - {{ stats.info }} - {{ stats.warning }} - {{ stats.failure }} + | {{ report.description|markdown|placeholder }} | + {% if last_job %} ++ {{ last_job.created|annotated_date }} + | ++ {% badge last_job.get_status_display last_job.get_status_color %} + | + {% else %} +Never | +{{ ''|placeholder }} | + {% endif %} ++ {% if perms.extras.run_report %} + | + {% endif %}