diff --git a/CHANGELOG.md b/CHANGELOG.md index b2a5445fe..d96ca2ad7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,13 @@ v2.5.8 (FUTURE) ## Bug Fixes +* [#2705](https://github.com/digitalocean/netbox/issues/2705) - Fix endpoint grouping in API docs +* [#2781](https://github.com/digitalocean/netbox/issues/2781) - Fix filtering of sites/devices/VMs by multiple regions * [#2923](https://github.com/digitalocean/netbox/issues/2923) - Provider filter form's site field should be blank by default +* [#2938](https://github.com/digitalocean/netbox/issues/2938) - Enforce deterministic ordering of device components returned by API +* [#2939](https://github.com/digitalocean/netbox/issues/2939) - Exclude circuit terminations from API interface connections endpoint +* [#2952](https://github.com/digitalocean/netbox/issues/2952) - Added the `slug` field to the Tenant filter for use in the API and search function +* [#2954](https://github.com/digitalocean/netbox/issues/2954) - Remove trailing slashes to fix root/template paths on Windows --- diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 4e14d8163..8fddc7129 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -496,11 +496,11 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet): class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet): queryset = Interface.objects.select_related( - 'device', '_connected_interface', '_connected_circuittermination' + 'device', '_connected_interface__device' ).filter( # Avoid duplicate connections by only selecting the lower PK in a connected pair - Q(_connected_interface__isnull=False, pk__lt=F('_connected_interface')) | - Q(_connected_circuittermination__isnull=False) + _connected_interface__isnull=False, + pk__lt=F('_connected_interface') ) serializer_class = serializers.InterfaceConnectionSerializer filterset_class = filters.InterfaceConnectionFilter diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 7d609426d..dc2e411d6 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -1,6 +1,5 @@ import django_filters from django.contrib.auth.models import User -from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q from netaddr import EUI from netaddr.core import AddrFormatError @@ -8,7 +7,9 @@ from netaddr.core import AddrFormatError from extras.filters import CustomFieldFilterSet from tenancy.filters import TenancyFilterSet from utilities.constants import COLOR_CHOICES -from utilities.filters import NameSlugSearchFilterSet, NullableCharFieldFilter, NumericInFilter, TagFilter +from utilities.filters import ( + NameSlugSearchFilterSet, NullableCharFieldFilter, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter +) from virtualization.models import Cluster from .constants import * from .models import ( @@ -49,14 +50,15 @@ class SiteFilter(TenancyFilterSet, CustomFieldFilterSet, django_filters.FilterSe choices=SITE_STATUS_CHOICES, null_value=None ) - region_id = django_filters.NumberFilter( - method='filter_region', - field_name='pk', + region_id = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='region__in', label='Region (ID)', ) - region = django_filters.CharFilter( - method='filter_region', - field_name='slug', + region = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='region__in', + to_field_name='slug', label='Region (slug)', ) tag = TagFilter() @@ -85,16 +87,6 @@ class SiteFilter(TenancyFilterSet, CustomFieldFilterSet, django_filters.FilterSe pass return queryset.filter(qs_filter) - def filter_region(self, queryset, name, value): - try: - region = Region.objects.get(**{name: value}) - except ObjectDoesNotExist: - return queryset.none() - return queryset.filter( - Q(region=region) | - Q(region__in=region.get_descendants()) - ) - class RackGroupFilter(NameSlugSearchFilterSet): site_id = django_filters.ModelMultipleChoiceFilter( @@ -473,14 +465,15 @@ class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet): ) name = NullableCharFieldFilter() asset_tag = NullableCharFieldFilter() - region_id = django_filters.NumberFilter( - method='filter_region', - field_name='pk', + region_id = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='site__region__in', label='Region (ID)', ) - region = django_filters.CharFilter( - method='filter_region', - field_name='slug', + region = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='site__region__in', + to_field_name='slug', label='Region (slug)', ) site_id = django_filters.ModelMultipleChoiceFilter( @@ -579,16 +572,6 @@ class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet): Q(comments__icontains=value) ).distinct() - def filter_region(self, queryset, name, value): - try: - region = Region.objects.get(**{name: value}) - except ObjectDoesNotExist: - return queryset.none() - return queryset.filter( - Q(site__region=region) | - Q(site__region__in=region.get_descendants()) - ) - def _mac_address(self, queryset, name, value): value = value.strip() if not value: diff --git a/netbox/dcim/managers.py b/netbox/dcim/managers.py index 52df1afe8..feaa09d74 100644 --- a/netbox/dcim/managers.py +++ b/netbox/dcim/managers.py @@ -27,7 +27,7 @@ class DeviceComponentManager(Manager): select={ 'name_padded': sql.format(table_name, table_name), } - ).order_by('name_padded') + ).order_by('name_padded', 'pk') class InterfaceQuerySet(QuerySet): diff --git a/netbox/extras/middleware.py b/netbox/extras/middleware.py index 16461c32a..38dde6275 100644 --- a/netbox/extras/middleware.py +++ b/netbox/extras/middleware.py @@ -29,7 +29,11 @@ def cache_changed_object(instance, **kwargs): def _record_object_deleted(request, instance, **kwargs): - # Record that the object was deleted. + # Force resolution of request.user in case it's still a SimpleLazyObject. This seems to happen + # occasionally during tests, but haven't been able to determine why. + assert request.user.is_authenticated + + # Record that the object was deleted if hasattr(instance, 'log_change'): instance.log_change(request.user, request.id, OBJECTCHANGE_ACTION_DELETE) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index a85a5d78e..c2622998f 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -197,7 +197,7 @@ ROOT_URLCONF = 'netbox.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [BASE_DIR + '/templates/'], + 'DIRS': [BASE_DIR + '/templates'], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -223,7 +223,7 @@ USE_I18N = True USE_TZ = True # Static files (CSS, JavaScript, Images) -STATIC_ROOT = BASE_DIR + '/static/' +STATIC_ROOT = BASE_DIR + '/static' STATIC_URL = '/{}static/'.format(BASE_PATH) STATICFILES_DIRS = ( os.path.join(BASE_DIR, "project-static"), diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index ff11e3892..837d9473d 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -267,6 +267,7 @@ class SearchView(View): class APIRootView(APIView): _ignore_model_permissions = True exclude_from_schema = True + swagger_schema = None def get_view_name(self): return "API Root" diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py index a7bf19f59..b2c8e7934 100644 --- a/netbox/tenancy/filters.py +++ b/netbox/tenancy/filters.py @@ -36,13 +36,14 @@ class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = Tenant - fields = ['name'] + fields = ['name', 'slug'] def search(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter( Q(name__icontains=value) | + Q(slug__icontains=value) | Q(description__icontains=value) | Q(comments__icontains=value) ) diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 40e687077..b0c2b3ec3 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -4,6 +4,15 @@ from django.db.models import Q from taggit.models import Tag +class TreeNodeMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter): + """ + Filters for a set of Models, including all descendant models within a Tree. Example: [,] + """ + def filter(self, qs, value): + value = [node.get_descendants(include_self=True) for node in value] + return super().filter(qs, value) + + class NumericInFilter(django_filters.BaseInFilter, django_filters.NumberFilter): """ Filters for a set of numeric values. Example: id__in=100,200,300 diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index 5224d11ba..0e5ff6cd2 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -6,8 +6,8 @@ from netaddr.core import AddrFormatError from dcim.models import DeviceRole, Interface, Platform, Region, Site from extras.filters import CustomFieldFilterSet -from tenancy.filters import TenancyFilterSet -from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter +from tenancy.models import Tenant +from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter from .constants import VM_STATUS_CHOICES from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -80,7 +80,7 @@ class ClusterFilter(CustomFieldFilterSet): ) -class VirtualMachineFilter(TenancyFilterSet, CustomFieldFilterSet): +class VirtualMachineFilter(CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -119,14 +119,15 @@ class VirtualMachineFilter(TenancyFilterSet, CustomFieldFilterSet): queryset=Cluster.objects.all(), label='Cluster (ID)', ) - region_id = django_filters.NumberFilter( - method='filter_region', - field_name='pk', + region_id = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='cluster__site__region__in', label='Region (ID)', ) - region = django_filters.CharFilter( - method='filter_region', - field_name='slug', + region = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='cluster__site__region__in', + to_field_name='slug', label='Region (slug)', ) site_id = django_filters.ModelMultipleChoiceFilter( @@ -150,6 +151,16 @@ class VirtualMachineFilter(TenancyFilterSet, CustomFieldFilterSet): to_field_name='slug', label='Role (slug)', ) + tenant_id = django_filters.ModelMultipleChoiceFilter( + queryset=Tenant.objects.all(), + label='Tenant (ID)', + ) + tenant = django_filters.ModelMultipleChoiceFilter( + field_name='tenant__slug', + queryset=Tenant.objects.all(), + to_field_name='slug', + label='Tenant (slug)', + ) platform_id = django_filters.ModelMultipleChoiceFilter( queryset=Platform.objects.all(), label='Platform (ID)', @@ -174,16 +185,6 @@ class VirtualMachineFilter(TenancyFilterSet, CustomFieldFilterSet): Q(comments__icontains=value) ) - def filter_region(self, queryset, name, value): - try: - region = Region.objects.get(**{name: value}) - except ObjectDoesNotExist: - return queryset.none() - return queryset.filter( - Q(cluster__site__region=region) | - Q(cluster__site__region__in=region.get_descendants()) - ) - class InterfaceFilter(django_filters.FilterSet): q = django_filters.CharFilter(