jeremystretch 4 лет назад
Родитель
Сommit
456ffb79ff

+ 0 - 1
.github/FUNDING.yml

@@ -1 +0,0 @@
-github: [jeremystretch]

+ 2 - 2
docs/installation/3-netbox.md

@@ -198,7 +198,7 @@ All Python packages required by NetBox are listed in `requirements.txt` and will
 The [NAPALM automation](https://napalm-automation.net/) library allows NetBox to fetch live data from devices and return it to a requester via its REST API. The `NAPALM_USERNAME` and `NAPALM_PASSWORD` configuration parameters define the credentials to be used when connecting to a device.
 The [NAPALM automation](https://napalm-automation.net/) library allows NetBox to fetch live data from devices and return it to a requester via its REST API. The `NAPALM_USERNAME` and `NAPALM_PASSWORD` configuration parameters define the credentials to be used when connecting to a device.
 
 
 ```no-highlight
 ```no-highlight
-sudo echo napalm >> /opt/netbox/local_requirements.txt
+sudo sh -c "echo 'napalm' >> /opt/netbox/local_requirements.txt"
 ```
 ```
 
 
 ### Remote File Storage
 ### Remote File Storage
@@ -206,7 +206,7 @@ sudo echo napalm >> /opt/netbox/local_requirements.txt
 By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](../configuration/optional-settings.md#storage_backend) in `configuration.py`.
 By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](../configuration/optional-settings.md#storage_backend) in `configuration.py`.
 
 
 ```no-highlight
 ```no-highlight
-sudo echo django-storages >> /opt/netbox/local_requirements.txt
+sudo sh -c "echo 'django-storages' >> /opt/netbox/local_requirements.txt"
 ```
 ```
 
 
 ## Run the Upgrade Script
 ## Run the Upgrade Script

+ 1 - 1
docs/installation/6-ldap.md

@@ -30,7 +30,7 @@ pip3 install django-auth-ldap
 Once installed, add the package to `local_requirements.txt` to ensure it is re-installed during future rebuilds of the virtual environment:
 Once installed, add the package to `local_requirements.txt` to ensure it is re-installed during future rebuilds of the virtual environment:
 
 
 ```no-highlight
 ```no-highlight
-sudo echo django-auth-ldap >> /opt/netbox/local_requirements.txt
+sudo sh -c "echo 'django-auth-ldap' >> /opt/netbox/local_requirements.txt"
 ```
 ```
 
 
 ## Configuration
 ## Configuration

+ 23 - 0
docs/release-notes/version-2.11.md

@@ -1,5 +1,27 @@
 # NetBox v2.11
 # NetBox v2.11
 
 
+## v2.11.2 (2021-04-27)
+
+### Enhancements
+
+* [#6275](https://github.com/netbox-community/netbox/issues/6275) - Linkify rack, device counts on locations list
+* [#6278](https://github.com/netbox-community/netbox/issues/6278) - Note device locations on cable traces
+* [#6287](https://github.com/netbox-community/netbox/issues/6287) - Add option to clear assigned max length filter on prefixes list
+
+### Bug Fixes
+
+* [#6236](https://github.com/netbox-community/netbox/issues/6236) - Journal entry title should account for configured timezone
+* [#6246](https://github.com/netbox-community/netbox/issues/6246) - Permit full-length descriptions when creating device components and VM interfaces
+* [#6248](https://github.com/netbox-community/netbox/issues/6248) - Fix table column reconfiguration under Chrome
+* [#6252](https://github.com/netbox-community/netbox/issues/6252) - Fix assignment of console port speed values above 19.2kbps
+* [#6254](https://github.com/netbox-community/netbox/issues/6254) - Disable ordering of space column in racks table
+* [#6258](https://github.com/netbox-community/netbox/issues/6258) - Fix parent assignment for SiteGroup API serializer
+* [#6262](https://github.com/netbox-community/netbox/issues/6262) - Support filtering by created/updated time for all relevant objects
+* [#6267](https://github.com/netbox-community/netbox/issues/6267) - Fix cable tracing API endpoint for circuit terminations
+* [#6289](https://github.com/netbox-community/netbox/issues/6289) - Fix assignment of VC member interfaces to LAG interfaces
+
+---
+
 ## v2.11.1 (2021-04-21)
 ## v2.11.1 (2021-04-21)
 
 
 ### Enhancements
 ### Enhancements
@@ -175,6 +197,7 @@ A new provider network model has been introduced to represent the boundary of a
 * circuits.CircuitTermination
 * circuits.CircuitTermination
     * Added the `provider_network` field
     * Added the `provider_network` field
     * Removed the `connected_endpoint`, `connected_endpoint_type`, and `connected_endpoint_reachable` fields
     * Removed the `connected_endpoint`, `connected_endpoint_type`, and `connected_endpoint_reachable` fields
+    * The `trace/` endpoint has been replaced with `paths/`
 * circuits.ProviderNetwork
 * circuits.ProviderNetwork
     * Added the `/api/circuits/provider-networks/` endpoint
     * Added the `/api/circuits/provider-networks/` endpoint
 * dcim.Device
 * dcim.Device

+ 2 - 2
netbox/circuits/api/views.py

@@ -2,7 +2,7 @@ from rest_framework.routers import APIRootView
 
 
 from circuits import filters
 from circuits import filters
 from circuits.models import *
 from circuits.models import *
-from dcim.api.views import PathEndpointMixin
+from dcim.api.views import PassThroughPortMixin
 from extras.api.views import CustomFieldModelViewSet
 from extras.api.views import CustomFieldModelViewSet
 from netbox.api.views import ModelViewSet
 from netbox.api.views import ModelViewSet
 from utilities.utils import count_related
 from utilities.utils import count_related
@@ -57,7 +57,7 @@ class CircuitViewSet(CustomFieldModelViewSet):
 # Circuit Terminations
 # Circuit Terminations
 #
 #
 
 
-class CircuitTerminationViewSet(PathEndpointMixin, ModelViewSet):
+class CircuitTerminationViewSet(PassThroughPortMixin, ModelViewSet):
     queryset = CircuitTermination.objects.prefetch_related(
     queryset = CircuitTermination.objects.prefetch_related(
         'circuit', 'site', 'provider_network', 'cable'
         'circuit', 'site', 'provider_network', 'cable'
     )
     )

+ 3 - 3
netbox/circuits/filters.py

@@ -1,7 +1,7 @@
 import django_filters
 import django_filters
 from django.db.models import Q
 from django.db.models import Q
 
 
-from dcim.filters import CableTerminationFilterSet, PathEndpointFilterSet
+from dcim.filters import CableTerminationFilterSet
 from dcim.models import Region, Site, SiteGroup
 from dcim.models import Region, Site, SiteGroup
 from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet
 from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet
 from tenancy.filters import TenancyFilterSet
 from tenancy.filters import TenancyFilterSet
@@ -110,7 +110,7 @@ class ProviderNetworkFilterSet(BaseFilterSet, CustomFieldModelFilterSet, Created
         ).distinct()
         ).distinct()
 
 
 
 
-class CircuitTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
+class CircuitTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
 
 
     class Meta:
     class Meta:
         model = CircuitType
         model = CircuitType
@@ -207,7 +207,7 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe
         ).distinct()
         ).distinct()
 
 
 
 
-class CircuitTerminationFilterSet(BaseFilterSet, CableTerminationFilterSet):
+class CircuitTerminationFilterSet(BaseFilterSet, CreatedUpdatedFilterSet, CableTerminationFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',

+ 1 - 1
netbox/dcim/api/serializers.py

@@ -90,7 +90,7 @@ class RegionSerializer(NestedGroupModelSerializer):
 
 
 class SiteGroupSerializer(NestedGroupModelSerializer):
 class SiteGroupSerializer(NestedGroupModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail')
-    parent = NestedRegionSerializer(required=False, allow_null=True)
+    parent = NestedSiteGroupSerializer(required=False, allow_null=True)
     site_count = serializers.IntegerField(read_only=True)
     site_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:

+ 15 - 15
netbox/dcim/filters.py

@@ -57,7 +57,7 @@ __all__ = (
 )
 )
 
 
 
 
-class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
+class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         label='Parent region (ID)',
         label='Parent region (ID)',
@@ -74,7 +74,7 @@ class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
         fields = ['id', 'name', 'slug', 'description']
         fields = ['id', 'name', 'slug', 'description']
 
 
 
 
-class SiteGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
+class SiteGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=SiteGroup.objects.all(),
         queryset=SiteGroup.objects.all(),
         label='Parent site group (ID)',
         label='Parent site group (ID)',
@@ -154,7 +154,7 @@ class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
         return queryset.filter(qs_filter)
         return queryset.filter(qs_filter)
 
 
 
 
-class LocationFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
+class LocationFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         field_name='site__region',
         field_name='site__region',
@@ -218,7 +218,7 @@ class LocationFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
         )
         )
 
 
 
 
-class RackRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
+class RackRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
 
 
     class Meta:
     class Meta:
         model = RackRole
         model = RackRole
@@ -323,7 +323,7 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
         )
         )
 
 
 
 
-class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet):
+class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -383,7 +383,7 @@ class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModel
         )
         )
 
 
 
 
-class ManufacturerFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
+class ManufacturerFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
 
 
     class Meta:
     class Meta:
         model = Manufacturer
         model = Manufacturer
@@ -476,7 +476,7 @@ class DeviceTypeFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdat
         return queryset.exclude(devicebaytemplates__isnull=value)
         return queryset.exclude(devicebaytemplates__isnull=value)
 
 
 
 
-class DeviceTypeComponentFilterSet(NameSlugSearchFilterSet):
+class DeviceTypeComponentFilterSet(NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
     devicetype_id = django_filters.ModelMultipleChoiceFilter(
     devicetype_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DeviceType.objects.all(),
         queryset=DeviceType.objects.all(),
         field_name='device_type_id',
         field_name='device_type_id',
@@ -556,14 +556,14 @@ class DeviceBayTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
         fields = ['id', 'name']
         fields = ['id', 'name']
 
 
 
 
-class DeviceRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
+class DeviceRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
 
 
     class Meta:
     class Meta:
         model = DeviceRole
         model = DeviceRole
         fields = ['id', 'name', 'slug', 'color', 'vm_role']
         fields = ['id', 'name', 'slug', 'color', 'vm_role']
 
 
 
 
-class PlatformFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
+class PlatformFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
         field_name='manufacturer',
         field_name='manufacturer',
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
@@ -792,7 +792,7 @@ class DeviceFilterSet(
         return queryset.exclude(devicebays__isnull=value)
         return queryset.exclude(devicebays__isnull=value)
 
 
 
 
-class DeviceComponentFilterSet(CustomFieldModelFilterSet):
+class DeviceComponentFilterSet(CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -984,7 +984,7 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
             devices = Device.objects.filter(**{'{}__in'.format(name): value})
             devices = Device.objects.filter(**{'{}__in'.format(name): value})
             vc_interface_ids = []
             vc_interface_ids = []
             for device in devices:
             for device in devices:
-                vc_interface_ids.extend(device.vc_interfaces.values_list('id', flat=True))
+                vc_interface_ids.extend(device.vc_interfaces().values_list('id', flat=True))
             return queryset.filter(pk__in=vc_interface_ids)
             return queryset.filter(pk__in=vc_interface_ids)
         except Device.DoesNotExist:
         except Device.DoesNotExist:
             return queryset.none()
             return queryset.none()
@@ -995,7 +995,7 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
         try:
         try:
             devices = Device.objects.filter(pk__in=id_list)
             devices = Device.objects.filter(pk__in=id_list)
             for device in devices:
             for device in devices:
-                vc_interface_ids += device.vc_interfaces.values_list('id', flat=True)
+                vc_interface_ids += device.vc_interfaces().values_list('id', flat=True)
             return queryset.filter(pk__in=vc_interface_ids)
             return queryset.filter(pk__in=vc_interface_ids)
         except Device.DoesNotExist:
         except Device.DoesNotExist:
             return queryset.none()
             return queryset.none()
@@ -1129,7 +1129,7 @@ class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet):
         return queryset.filter(qs_filter)
         return queryset.filter(qs_filter)
 
 
 
 
-class VirtualChassisFilterSet(BaseFilterSet, CustomFieldModelFilterSet):
+class VirtualChassisFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -1209,7 +1209,7 @@ class VirtualChassisFilterSet(BaseFilterSet, CustomFieldModelFilterSet):
         return queryset.filter(qs_filter).distinct()
         return queryset.filter(qs_filter).distinct()
 
 
 
 
-class CableFilterSet(BaseFilterSet, CustomFieldModelFilterSet):
+class CableFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -1340,7 +1340,7 @@ class InterfaceConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):
         fields = []
         fields = []
 
 
 
 
-class PowerPanelFilterSet(BaseFilterSet):
+class PowerPanelFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',

+ 2 - 2
netbox/dcim/forms.py

@@ -2153,7 +2153,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
                 ip_choices = [(None, '---------')]
                 ip_choices = [(None, '---------')]
 
 
                 # Gather PKs of all interfaces belonging to this Device or a peer VirtualChassis member
                 # Gather PKs of all interfaces belonging to this Device or a peer VirtualChassis member
-                interface_ids = self.instance.vc_interfaces.values_list('pk', flat=True)
+                interface_ids = self.instance.vc_interfaces().values_list('pk', flat=True)
 
 
                 # Collect interface IPs
                 # Collect interface IPs
                 interface_ips = IPAddress.objects.filter(
                 interface_ips = IPAddress.objects.filter(
@@ -2552,7 +2552,7 @@ class ComponentCreateForm(BootstrapMixin, CustomFieldForm, ComponentForm):
         queryset=Device.objects.all()
         queryset=Device.objects.all()
     )
     )
     description = forms.CharField(
     description = forms.CharField(
-        max_length=100,
+        max_length=200,
         required=False
         required=False
     )
     )
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(

+ 21 - 0
netbox/dcim/migrations/0131_consoleport_speed.py

@@ -0,0 +1,21 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0130_sitegroup'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='consoleport',
+            name='speed',
+            field=models.PositiveIntegerField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='consoleserverport',
+            name='speed',
+            field=models.PositiveIntegerField(blank=True, null=True),
+        ),
+    ]

+ 2 - 2
netbox/dcim/models/device_components.py

@@ -222,7 +222,7 @@ class ConsolePort(ComponentModel, CableTermination, PathEndpoint):
         blank=True,
         blank=True,
         help_text='Physical port type'
         help_text='Physical port type'
     )
     )
-    speed = models.PositiveSmallIntegerField(
+    speed = models.PositiveIntegerField(
         choices=ConsolePortSpeedChoices,
         choices=ConsolePortSpeedChoices,
         blank=True,
         blank=True,
         null=True,
         null=True,
@@ -265,7 +265,7 @@ class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint):
         blank=True,
         blank=True,
         help_text='Physical port type'
         help_text='Physical port type'
     )
     )
-    speed = models.PositiveSmallIntegerField(
+    speed = models.PositiveIntegerField(
         choices=ConsolePortSpeedChoices,
         choices=ConsolePortSpeedChoices,
         blank=True,
         blank=True,
         null=True,
         null=True,

+ 11 - 4
netbox/dcim/models/devices.py

@@ -716,7 +716,7 @@ class Device(PrimaryModel, ConfigContextModel):
                 pass
                 pass
 
 
         # Validate primary IP addresses
         # Validate primary IP addresses
-        vc_interfaces = self.vc_interfaces.all()
+        vc_interfaces = self.vc_interfaces()
         if self.primary_ip4:
         if self.primary_ip4:
             if self.primary_ip4.family != 4:
             if self.primary_ip4.family != 4:
                 raise ValidationError({
                 raise ValidationError({
@@ -854,20 +854,27 @@ class Device(PrimaryModel, ConfigContextModel):
         else:
         else:
             return None
             return None
 
 
+    @property
+    def interfaces_count(self):
+        if self.virtual_chassis and self.virtual_chassis.master == self:
+            return self.vc_interfaces().count()
+        return self.interfaces.count()
+
     def get_vc_master(self):
     def get_vc_master(self):
         """
         """
         If this Device is a VirtualChassis member, return the VC master. Otherwise, return None.
         If this Device is a VirtualChassis member, return the VC master. Otherwise, return None.
         """
         """
         return self.virtual_chassis.master if self.virtual_chassis else None
         return self.virtual_chassis.master if self.virtual_chassis else None
 
 
-    @property
-    def vc_interfaces(self):
+    def vc_interfaces(self, if_master=False):
         """
         """
         Return a QuerySet matching all Interfaces assigned to this Device or, if this Device is a VC master, to another
         Return a QuerySet matching all Interfaces assigned to this Device or, if this Device is a VC master, to another
         Device belonging to the same VirtualChassis.
         Device belonging to the same VirtualChassis.
+
+        :param if_master: If True, return VC member interfaces only if this Device is the VC master.
         """
         """
         filter = Q(device=self)
         filter = Q(device=self)
-        if self.virtual_chassis and self.virtual_chassis.master == self:
+        if self.virtual_chassis and (not if_master or self.virtual_chassis.master == self):
             filter |= Q(device__virtual_chassis=self.virtual_chassis, mgmt_only=False)
             filter |= Q(device__virtual_chassis=self.virtual_chassis, mgmt_only=False)
         return Interface.objects.filter(filter)
         return Interface.objects.filter(filter)
 
 

+ 1 - 0
netbox/dcim/tables/racks.py

@@ -73,6 +73,7 @@ class RackDetailTable(RackTable):
         verbose_name='Devices'
         verbose_name='Devices'
     )
     )
     get_utilization = UtilizationColumn(
     get_utilization = UtilizationColumn(
+        orderable=False,
         verbose_name='Space'
         verbose_name='Space'
     )
     )
     get_power_utilization = UtilizationColumn(
     get_power_utilization = UtilizationColumn(

+ 6 - 2
netbox/dcim/tables/sites.py

@@ -102,10 +102,14 @@ class LocationTable(BaseTable):
     site = tables.Column(
     site = tables.Column(
         linkify=True
         linkify=True
     )
     )
-    rack_count = tables.Column(
+    rack_count = LinkedCountColumn(
+        viewname='dcim:rack_list',
+        url_params={'location_id': 'pk'},
         verbose_name='Racks'
         verbose_name='Racks'
     )
     )
-    device_count = tables.Column(
+    device_count = LinkedCountColumn(
+        viewname='dcim:device_list',
+        url_params={'location_id': 'pk'},
         verbose_name='Devices'
         verbose_name='Devices'
     )
     )
     actions = ButtonsColumn(
     actions = ButtonsColumn(

+ 2 - 2
netbox/dcim/views.py

@@ -1405,7 +1405,7 @@ class DeviceInterfacesView(generic.ObjectView):
     template_name = 'dcim/device/interfaces.html'
     template_name = 'dcim/device/interfaces.html'
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
-        interfaces = instance.vc_interfaces.restrict(request.user, 'view').prefetch_related(
+        interfaces = instance.vc_interfaces(if_master=True).restrict(request.user, 'view').prefetch_related(
             Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)),
             Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)),
             Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)),
             Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)),
             'lag', 'cable', '_path__destination', 'tags',
             'lag', 'cable', '_path__destination', 'tags',
@@ -1527,7 +1527,7 @@ class DeviceLLDPNeighborsView(generic.ObjectView):
     template_name = 'dcim/device/lldp_neighbors.html'
     template_name = 'dcim/device/lldp_neighbors.html'
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
-        interfaces = instance.vc_interfaces.restrict(request.user, 'view').prefetch_related(
+        interfaces = instance.vc_interfaces(if_master=True).restrict(request.user, 'view').prefetch_related(
             '_path__destination'
             '_path__destination'
         ).exclude(
         ).exclude(
             type__in=NONCONNECTABLE_IFACE_TYPES
             type__in=NONCONNECTABLE_IFACE_TYPES

+ 24 - 24
netbox/extras/filters.py

@@ -36,6 +36,27 @@ EXACT_FILTER_TYPES = (
 )
 )
 
 
 
 
+class CreatedUpdatedFilterSet(django_filters.FilterSet):
+    created = django_filters.DateFilter()
+    created__gte = django_filters.DateFilter(
+        field_name='created',
+        lookup_expr='gte'
+    )
+    created__lte = django_filters.DateFilter(
+        field_name='created',
+        lookup_expr='lte'
+    )
+    last_updated = django_filters.DateTimeFilter()
+    last_updated__gte = django_filters.DateTimeFilter(
+        field_name='last_updated',
+        lookup_expr='gte'
+    )
+    last_updated__lte = django_filters.DateTimeFilter(
+        field_name='last_updated',
+        lookup_expr='lte'
+    )
+
+
 class WebhookFilterSet(BaseFilterSet):
 class WebhookFilterSet(BaseFilterSet):
     content_types = ContentTypeFilter()
     content_types = ContentTypeFilter()
     http_method = django_filters.MultipleChoiceFilter(
     http_method = django_filters.MultipleChoiceFilter(
@@ -119,7 +140,7 @@ class ImageAttachmentFilterSet(BaseFilterSet):
         fields = ['id', 'content_type_id', 'object_id', 'name']
         fields = ['id', 'content_type_id', 'object_id', 'name']
 
 
 
 
-class JournalEntryFilterSet(BaseFilterSet):
+class JournalEntryFilterSet(BaseFilterSet, CreatedUpdatedFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -150,7 +171,7 @@ class JournalEntryFilterSet(BaseFilterSet):
         return queryset.filter(comments__icontains=value)
         return queryset.filter(comments__icontains=value)
 
 
 
 
-class TagFilterSet(BaseFilterSet):
+class TagFilterSet(BaseFilterSet, CreatedUpdatedFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -169,7 +190,7 @@ class TagFilterSet(BaseFilterSet):
         )
         )
 
 
 
 
-class ConfigContextFilterSet(BaseFilterSet):
+class ConfigContextFilterSet(BaseFilterSet, CreatedUpdatedFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -341,27 +362,6 @@ class ObjectChangeFilterSet(BaseFilterSet):
         )
         )
 
 
 
 
-class CreatedUpdatedFilterSet(django_filters.FilterSet):
-    created = django_filters.DateFilter()
-    created__gte = django_filters.DateFilter(
-        field_name='created',
-        lookup_expr='gte'
-    )
-    created__lte = django_filters.DateFilter(
-        field_name='created',
-        lookup_expr='lte'
-    )
-    last_updated = django_filters.DateTimeFilter()
-    last_updated__gte = django_filters.DateTimeFilter(
-        field_name='last_updated',
-        lookup_expr='gte'
-    )
-    last_updated__lte = django_filters.DateTimeFilter(
-        field_name='last_updated',
-        lookup_expr='lte'
-    )
-
-
 #
 #
 # Job Results
 # Job Results
 #
 #

+ 3 - 1
netbox/extras/models/models.py

@@ -431,7 +431,9 @@ class JournalEntry(ChangeLoggedModel):
         verbose_name_plural = 'journal entries'
         verbose_name_plural = 'journal entries'
 
 
     def __str__(self):
     def __str__(self):
-        return f"{date_format(self.created)} - {time_format(self.created)} ({self.get_kind_display()})"
+        created_date = timezone.localdate(self.created)
+        created_time = timezone.localtime(self.created)
+        return f"{date_format(created_date)} - {time_format(created_time)} ({self.get_kind_display()})"
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
         return reverse('extras:journalentry', args=[self.pk])
         return reverse('extras:journalentry', args=[self.pk])

+ 4 - 4
netbox/ipam/filters.py

@@ -116,7 +116,7 @@ class RouteTargetFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilt
         fields = ['id', 'name']
         fields = ['id', 'name']
 
 
 
 
-class RIRFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
+class RIRFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
 
 
     class Meta:
     class Meta:
         model = RIR
         model = RIR
@@ -173,7 +173,7 @@ class AggregateFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilter
             return queryset.none()
             return queryset.none()
 
 
 
 
-class RoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
+class RoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -515,7 +515,7 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilter
             return queryset.none()
             return queryset.none()
         interface_ids = []
         interface_ids = []
         for device in devices:
         for device in devices:
-            interface_ids.extend(device.vc_interfaces.values_list('id', flat=True))
+            interface_ids.extend(device.vc_interfaces().values_list('id', flat=True))
         return queryset.filter(
         return queryset.filter(
             interface__in=interface_ids
             interface__in=interface_ids
         )
         )
@@ -535,7 +535,7 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilter
         return queryset.exclude(assigned_object_id__isnull=value)
         return queryset.exclude(assigned_object_id__isnull=value)
 
 
 
 
-class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
+class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
     scope_type = ContentTypeFilter()
     scope_type = ContentTypeFilter()
     region = django_filters.NumberFilter(
     region = django_filters.NumberFilter(
         method='filter_scope'
         method='filter_scope'

+ 1 - 1
netbox/ipam/forms.py

@@ -1561,7 +1561,7 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm):
         # Limit IP address choices to those assigned to interfaces of the parent device/VM
         # Limit IP address choices to those assigned to interfaces of the parent device/VM
         if self.instance.device:
         if self.instance.device:
             self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
             self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
-                interface__in=self.instance.device.vc_interfaces.values_list('id', flat=True)
+                interface__in=self.instance.device.vc_interfaces().values_list('id', flat=True)
             )
             )
         elif self.instance.virtual_machine:
         elif self.instance.virtual_machine:
             self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
             self.fields['ipaddresses'].queryset = IPAddress.objects.filter(

+ 1 - 1
netbox/secrets/filters.py

@@ -14,7 +14,7 @@ __all__ = (
 )
 )
 
 
 
 
-class SecretRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
+class SecretRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
 
 
     class Meta:
     class Meta:
         model = SecretRole
         model = SecretRole

+ 1 - 1
netbox/templates/dcim/device/base.html

@@ -119,7 +119,7 @@
         Device
         Device
     </a>
     </a>
 </li>
 </li>
-{% with interface_count=object.vc_interfaces.count %}
+{% with interface_count=object.interfaces_count %}
 {% if interface_count %}
 {% if interface_count %}
 <li role="presentation" class="nav-item">
 <li role="presentation" class="nav-item">
     <a class="nav-link {% if active_tab == 'interfaces' %} active{% endif %}" href="{% url 'dcim:device_interfaces' pk=object.pk %}">Interfaces {% badge interface_count %}</a>
     <a class="nav-link {% if active_tab == 'interfaces' %} active{% endif %}" href="{% url 'dcim:device_interfaces' pk=object.pk %}">Interfaces {% badge interface_count %}</a>

+ 3 - 0
netbox/templates/dcim/trace/device.html

@@ -2,6 +2,9 @@
     <strong><a href="{{ device.get_absolute_url }}">{{ device }}</a></strong><br />
     <strong><a href="{{ device.get_absolute_url }}">{{ device }}</a></strong><br />
     {{ device.device_type.manufacturer }} {{ device.device_type }}<br />
     {{ device.device_type.manufacturer }} {{ device.device_type }}<br />
     <a href="{{ device.site.get_absolute_url }}">{{ device.site }}</a>
     <a href="{{ device.site.get_absolute_url }}">{{ device.site }}</a>
+    {% if device.location %}
+        / <a href="{{ device.location.get_absolute_url }}">{{ device.location }}</a>
+    {% endif %}
     {% if device.rack %}
     {% if device.rack %}
         / <a href="{{ device.rack.get_absolute_url }}">{{ device.rack }}</a>
         / <a href="{{ device.rack.get_absolute_url }}">{{ device.rack }}</a>
     {% endif %}
     {% endif %}

+ 5 - 0
netbox/templates/ipam/prefix_list.html

@@ -7,6 +7,11 @@
             Max Length{% if "mask_length__lte" in request.GET %}: {{ request.GET.mask_length__lte }}{% endif %}
             Max Length{% if "mask_length__lte" in request.GET %}: {{ request.GET.mask_length__lte }}{% endif %}
         </button>
         </button>
         <ul class="dropdown-menu" aria-labelledby="max_length">
         <ul class="dropdown-menu" aria-labelledby="max_length">
+            {% if request.GET.mask_length__lte %}
+                <li>
+                    <a class="dropdown-item" href="{% url 'ipam:prefix_list' %}{% querystring request mask_length__lte=None page=1 %}">Clear</a>
+                </li>
+            {% endif %}
             {% for i in "4,8,12,16,20,24,28,32,40,48,56,64"|split %}
             {% for i in "4,8,12,16,20,24,28,32,40,48,56,64"|split %}
                 <li><a class="dropdown-item" href="{% url 'ipam:prefix_list' %}{% querystring request mask_length__lte=i page=1 %}">
                 <li><a class="dropdown-item" href="{% url 'ipam:prefix_list' %}{% querystring request mask_length__lte=i page=1 %}">
                     {{ i }} {% if request.GET.mask_length__lte == i %}<i class="mdi mdi-check-bold"></i>{% endif %}
                     {{ i }} {% if request.GET.mask_length__lte == i %}<i class="mdi mdi-check-bold"></i>{% endif %}

+ 1 - 1
netbox/tenancy/filters.py

@@ -13,7 +13,7 @@ __all__ = (
 )
 )
 
 
 
 
-class TenantGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
+class TenantGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
         label='Tenant group (ID)',
         label='Tenant group (ID)',

+ 2 - 2
netbox/virtualization/filters.py

@@ -20,14 +20,14 @@ __all__ = (
 )
 )
 
 
 
 
-class ClusterTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
+class ClusterTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
 
 
     class Meta:
     class Meta:
         model = ClusterType
         model = ClusterType
         fields = ['id', 'name', 'slug', 'description']
         fields = ['id', 'name', 'slug', 'description']
 
 
 
 
-class ClusterGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
+class ClusterGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
 
 
     class Meta:
     class Meta:
         model = ClusterGroup
         model = ClusterGroup

+ 1 - 1
netbox/virtualization/forms.py

@@ -682,7 +682,7 @@ class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm):
         label='MAC Address'
         label='MAC Address'
     )
     )
     description = forms.CharField(
     description = forms.CharField(
-        max_length=100,
+        max_length=200,
         required=False
         required=False
     )
     )
     mode = forms.ChoiceField(
     mode = forms.ChoiceField(