Compare commits

..

32 Commits

Author SHA1 Message Date
Jeremy Stretch
1abaa5e60c Merge pull request #6292 from netbox-community/develop
Release v2.11.2
2021-04-27 10:45:12 -04:00
Jeremy Stretch
f4792eea04 Merge branch 'master' into develop 2021-04-27 10:24:13 -04:00
jeremystretch
1fe1e1ce7e Release v2.11.2 2021-04-27 10:20:36 -04:00
jeremystretch
58659cf3b6 Fixes #6262: Support filtering by created/updated time for all relevant objects 2021-04-27 10:04:28 -04:00
jeremystretch
9a588231c5 Fixes #6289: Fix assignment of VC member interfaces to LAG interfaces 2021-04-27 09:36:48 -04:00
jeremystretch
f408ad16e4 Closes #6287: Add option to clear assigned max length filter on prefixes list 2021-04-27 08:34:21 -04:00
jeremystretch
fecca5ad83 Fixes #6267: Fix cable tracing API endpoint for circuit terminations 2021-04-26 16:49:52 -04:00
jeremystretch
9e5d41c48a Remove myself from funding 2021-04-26 15:55:53 -04:00
jeremystretch
87b4cfff1a Closes #6278: Note device locations on cable traces 2021-04-26 15:31:53 -04:00
jeremystretch
19a2b9042f Closes #6275: Linkify rack, device counts on locations list 2021-04-26 15:28:35 -04:00
jeremystretch
2618dde1e2 Fixes #6236: Journal entry title should account for configured timezone 2021-04-23 15:27:58 -04:00
jeremystretch
1dd9f8c1d4 Fixes #6248: Fix table column reconfiguration under Chrome 2021-04-23 10:27:58 -04:00
jeremystretch
4939b6b641 Fixes #6252: Fix assignment of console port speed values above 19.2kbps 2021-04-23 10:07:11 -04:00
jeremystretch
52747e364a Fixes #6254: Disable ordering of space column in racks table 2021-04-23 09:44:29 -04:00
jeremystretch
1901e93b1e Fixes #6258: Fix parent assignment for SiteGroup API serializer 2021-04-23 09:37:22 -04:00
jeremystretch
648b9dd7d8 Closes #6239: Fix sudo invokations of echo 2021-04-22 14:11:58 -04:00
jeremystretch
396c91f8f7 Fixes #6246: Permit full-length descriptions when creating device components and VM interfaces 2021-04-22 14:05:07 -04:00
jeremystretch
83f520f7a8 PRVB 2021-04-21 10:37:51 -04:00
Jeremy Stretch
d8ae65a762 Merge pull request #6230 from netbox-community/develop
Release v2.11.1
2021-04-21 10:05:35 -04:00
Jeremy Stretch
efea511211 Merge branch 'master' into develop 2021-04-21 09:53:23 -04:00
jeremystretch
2564818c3e Release v2.11.1 2021-04-21 09:47:30 -04:00
jeremystretch
e6930d9601 Closes #6161: Enable ordering of device component tables 2021-04-20 20:21:52 -04:00
jeremystretch
4e405ce530 Closes #6210: Include child locations on location view 2021-04-20 14:15:12 -04:00
jeremystretch
88ffc9b145 Update GitHub issue templates 2021-04-20 11:37:03 -04:00
jeremystretch
9ed76400de Closes #6179: Enable natural ordering for virtual machines 2021-04-20 09:37:43 -04:00
jeremystretch
497e50c559 Closes #6190: Allow filtering devices with no location assigned 2021-04-19 16:55:57 -04:00
jeremystretch
7cf9e202a3 Fixes #6215: Restore tenancy section in virtual machine form 2021-04-19 16:50:56 -04:00
jeremystretch
620d222f98 Closes #6189: Add ability to search for locations by name or description 2021-04-19 15:56:39 -04:00
jeremystretch
a2d16143e3 Fixes #6188: Support custom field filtering for regions, site groups, and locations 2021-04-19 11:05:40 -04:00
jeremystretch
97c087ef5f Fixes #6196: Fix object list display for users with read-only permissions 2021-04-19 10:46:23 -04:00
jeremystretch
6bd4b3c167 Fixes #6184: Fix parent object table column in prefix IP addresses list 2021-04-16 13:07:41 -04:00
jeremystretch
89350a80ad PRVB 2021-04-16 10:32:21 -04:00
39 changed files with 275 additions and 123 deletions

1
.github/FUNDING.yml vendored
View File

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

View File

@@ -1,6 +1,6 @@
---
name: 🐛 Bug Report
about: Report a reproducible bug in the current release of NetBox
description: Report a reproducible bug in the current release of NetBox
labels: ["type: bug"]
body:
- type: markdown

View File

@@ -1,6 +1,6 @@
---
name: 📖 Documentation Change
about: Suggest an addition or modification to the NetBox documentation
description: Suggest an addition or modification to the NetBox documentation
labels: ["type: documentation"]
body:
- type: dropdown

View File

@@ -1,6 +1,6 @@
---
name: ✨ Feature Request
about: Propose a new NetBox feature or enhancement
description: Propose a new NetBox feature or enhancement
labels: ["type: feature"]
body:
- type: markdown

View File

@@ -1,6 +1,6 @@
---
name: 🏡 Housekeeping
about: A change pertaining to the codebase itself (developers only)
description: A change pertaining to the codebase itself (developers only)
labels: ["type: housekeeping"]
body:
- type: markdown

View File

@@ -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.
```no-highlight
sudo echo napalm >> /opt/netbox/local_requirements.txt
sudo sh -c "echo 'napalm' >> /opt/netbox/local_requirements.txt"
```
### 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`.
```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

View File

@@ -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:
```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

View File

@@ -1,5 +1,46 @@
# 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)
### Enhancements
* [#6161](https://github.com/netbox-community/netbox/issues/6161) - Enable ordering of device component tables
* [#6179](https://github.com/netbox-community/netbox/issues/6179) - Enable natural ordering for virtual machines
* [#6189](https://github.com/netbox-community/netbox/issues/6189) - Add ability to search for locations by name or description
* [#6190](https://github.com/netbox-community/netbox/issues/6190) - Allow filtering devices with no location assigned
* [#6210](https://github.com/netbox-community/netbox/issues/6210) - Include child locations on location view
### Bug Fixes
* [#6184](https://github.com/netbox-community/netbox/issues/6184) - Fix parent object table column in prefix IP addresses list
* [#6188](https://github.com/netbox-community/netbox/issues/6188) - Support custom field filtering for regions, site groups, and locations
* [#6196](https://github.com/netbox-community/netbox/issues/6196) - Fix object list display for users with read-only permissions
* [#6215](https://github.com/netbox-community/netbox/issues/6215) - Restore tenancy section in virtual machine form
---
## v2.11.0 (2021-04-16)
**Note:** NetBox v2.11 is the last major release that will support Python 3.6. Beginning with NetBox v2.12, Python 3.7 or later will be required.
@@ -156,6 +197,7 @@ A new provider network model has been introduced to represent the boundary of a
* circuits.CircuitTermination
* Added the `provider_network` field
* Removed the `connected_endpoint`, `connected_endpoint_type`, and `connected_endpoint_reachable` fields
* The `trace/` endpoint has been replaced with `paths/`
* circuits.ProviderNetwork
* Added the `/api/circuits/provider-networks/` endpoint
* dcim.Device

View File

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

View File

@@ -1,7 +1,7 @@
import django_filters
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 extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet
from tenancy.filters import TenancyFilterSet
@@ -110,7 +110,7 @@ class ProviderNetworkFilterSet(BaseFilterSet, CustomFieldModelFilterSet, Created
).distinct()
class CircuitTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
class CircuitTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
class Meta:
model = CircuitType
@@ -207,7 +207,7 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe
).distinct()
class CircuitTerminationFilterSet(BaseFilterSet, CableTerminationFilterSet):
class CircuitTerminationFilterSet(BaseFilterSet, CreatedUpdatedFilterSet, CableTerminationFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',

View File

@@ -90,7 +90,7 @@ class RegionSerializer(NestedGroupModelSerializer):
class SiteGroupSerializer(NestedGroupModelSerializer):
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)
class Meta:

View File

@@ -57,7 +57,7 @@ __all__ = (
)
class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=Region.objects.all(),
label='Parent region (ID)',
@@ -74,7 +74,7 @@ class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug', 'description']
class SiteGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
class SiteGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
label='Parent site group (ID)',
@@ -154,7 +154,7 @@ class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
return queryset.filter(qs_filter)
class LocationFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
class LocationFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region',
@@ -209,8 +209,16 @@ class LocationFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
model = Location
fields = ['id', 'name', 'slug', 'description']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value)
)
class RackRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
class RackRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
class Meta:
model = RackRole
@@ -315,7 +323,7 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
)
class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet):
class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -375,7 +383,7 @@ class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModel
)
class ManufacturerFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
class ManufacturerFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
class Meta:
model = Manufacturer
@@ -468,7 +476,7 @@ class DeviceTypeFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdat
return queryset.exclude(devicebaytemplates__isnull=value)
class DeviceTypeComponentFilterSet(NameSlugSearchFilterSet):
class DeviceTypeComponentFilterSet(NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
devicetype_id = django_filters.ModelMultipleChoiceFilter(
queryset=DeviceType.objects.all(),
field_name='device_type_id',
@@ -548,14 +556,14 @@ class DeviceBayTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
fields = ['id', 'name']
class DeviceRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
class DeviceRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
class Meta:
model = DeviceRole
fields = ['id', 'name', 'slug', 'color', 'vm_role']
class PlatformFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
class PlatformFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
field_name='manufacturer',
queryset=Manufacturer.objects.all(),
@@ -784,7 +792,7 @@ class DeviceFilterSet(
return queryset.exclude(devicebays__isnull=value)
class DeviceComponentFilterSet(CustomFieldModelFilterSet):
class DeviceComponentFilterSet(CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -976,7 +984,7 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
devices = Device.objects.filter(**{'{}__in'.format(name): value})
vc_interface_ids = []
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)
except Device.DoesNotExist:
return queryset.none()
@@ -987,7 +995,7 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
try:
devices = Device.objects.filter(pk__in=id_list)
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)
except Device.DoesNotExist:
return queryset.none()
@@ -1121,7 +1129,7 @@ class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet):
return queryset.filter(qs_filter)
class VirtualChassisFilterSet(BaseFilterSet, CustomFieldModelFilterSet):
class VirtualChassisFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -1201,7 +1209,7 @@ class VirtualChassisFilterSet(BaseFilterSet, CustomFieldModelFilterSet):
return queryset.filter(qs_filter).distinct()
class CableFilterSet(BaseFilterSet, CustomFieldModelFilterSet):
class CableFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -1332,7 +1340,7 @@ class InterfaceConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):
fields = []
class PowerPanelFilterSet(BaseFilterSet):
class PowerPanelFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',

View File

@@ -230,7 +230,7 @@ class RegionBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
nullable_fields = ['parent', 'description']
class RegionFilterForm(BootstrapMixin, forms.Form):
class RegionFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Site
q = forms.CharField(
required=False,
@@ -287,8 +287,8 @@ class SiteGroupBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
nullable_fields = ['parent', 'description']
class SiteGroupFilterForm(BootstrapMixin, forms.Form):
model = Site
class SiteGroupFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = SiteGroup
q = forms.CharField(
required=False,
label=_('Search')
@@ -557,7 +557,12 @@ class LocationBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
nullable_fields = ['parent', 'description']
class LocationFilterForm(BootstrapMixin, forms.Form):
class LocationFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Location
q = forms.CharField(
required=False,
label=_('Search')
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -2148,7 +2153,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
ip_choices = [(None, '---------')]
# 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
interface_ips = IPAddress.objects.filter(
@@ -2424,10 +2429,11 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.all(),
required=False,
label=_('Location'),
null_option='None',
query_params={
'site_id': '$site_id'
}
},
label=_('Location')
)
rack_id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(),
@@ -2546,7 +2552,7 @@ class ComponentCreateForm(BootstrapMixin, CustomFieldForm, ComponentForm):
queryset=Device.objects.all()
)
description = forms.CharField(
max_length=100,
max_length=200,
required=False
)
tags = DynamicModelMultipleChoiceField(

View File

@@ -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),
),
]

View File

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

View File

@@ -716,7 +716,7 @@ class Device(PrimaryModel, ConfigContextModel):
pass
# Validate primary IP addresses
vc_interfaces = self.vc_interfaces.all()
vc_interfaces = self.vc_interfaces()
if self.primary_ip4:
if self.primary_ip4.family != 4:
raise ValidationError({
@@ -854,20 +854,27 @@ class Device(PrimaryModel, ConfigContextModel):
else:
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):
"""
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
@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
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)
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)
return Interface.objects.filter(filter)

View File

@@ -291,6 +291,7 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable):
class DeviceConsolePortTable(ConsolePortTable):
name = tables.TemplateColumn(
template_code='<i class="mdi mdi-console"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}}
)
actions = ButtonsColumn(
@@ -335,6 +336,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
name = tables.TemplateColumn(
template_code='<i class="mdi mdi-console-network-outline"></i> '
'<a href="{{ record.get_absolute_url }}">{{ value }}</a>',
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}}
)
actions = ButtonsColumn(
@@ -379,6 +381,7 @@ class DevicePowerPortTable(PowerPortTable):
name = tables.TemplateColumn(
template_code='<i class="mdi mdi-power-plug-outline"></i> <a href="{{ record.get_absolute_url }}">'
'{{ value }}</a>',
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}}
)
actions = ButtonsColumn(
@@ -428,6 +431,7 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable):
class DevicePowerOutletTable(PowerOutletTable):
name = tables.TemplateColumn(
template_code='<i class="mdi mdi-power-socket"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}}
)
actions = ButtonsColumn(
@@ -492,6 +496,7 @@ class DeviceInterfaceTable(InterfaceTable):
template_code='<i class="mdi mdi-{% if iface.mgmt_only %}wrench{% elif iface.is_lag %}drag-horizontal-variant'
'{% elif iface.is_virtual %}circle{% elif iface.is_wireless %}wifi{% else %}ethernet'
'{% endif %}"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}}
)
parent = tables.Column(
@@ -555,6 +560,7 @@ class DeviceFrontPortTable(FrontPortTable):
name = tables.TemplateColumn(
template_code='<i class="mdi mdi-square-rounded{% if not record.cable %}-outline{% endif %}"></i> '
'<a href="{{ record.get_absolute_url }}">{{ value }}</a>',
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}}
)
actions = ButtonsColumn(
@@ -602,6 +608,7 @@ class DeviceRearPortTable(RearPortTable):
name = tables.TemplateColumn(
template_code='<i class="mdi mdi-square-rounded{% if not record.cable %}-outline{% endif %}"></i> '
'<a href="{{ record.get_absolute_url }}">{{ value }}</a>',
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}}
)
actions = ButtonsColumn(
@@ -651,6 +658,7 @@ class DeviceDeviceBayTable(DeviceBayTable):
name = tables.TemplateColumn(
template_code='<i class="mdi mdi-circle{% if record.installed_device %}slice-8{% else %}outline{% endif %}'
'"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}}
)
actions = ButtonsColumn(
@@ -698,6 +706,7 @@ class DeviceInventoryItemTable(InventoryItemTable):
name = tables.TemplateColumn(
template_code='<a href="{{ record.get_absolute_url }}" style="padding-left: {{ record.level }}0px">'
'{{ value }}</a>',
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}}
)
actions = ButtonsColumn(

View File

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

View File

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

View File

@@ -364,16 +364,30 @@ class LocationView(generic.ObjectView):
queryset = Location.objects.all()
def get_extra_context(self, request, instance):
devices = Device.objects.restrict(request.user, 'view').filter(
location=instance
)
location_ids = instance.get_descendants(include_self=True).values_list('pk', flat=True)
rack_count = Rack.objects.filter(location__in=location_ids).count()
device_count = Device.objects.filter(location__in=location_ids).count()
devices_table = tables.DeviceTable(devices)
devices_table.columns.hide('location')
paginate_table(devices_table, request)
child_locations = Location.objects.add_related_count(
Location.objects.add_related_count(
Location.objects.all(),
Device,
'location',
'device_count',
cumulative=True
),
Rack,
'location',
'rack_count',
cumulative=True
).filter(pk__in=location_ids).exclude(pk=instance.pk)
child_locations_table = tables.LocationTable(child_locations)
paginate_table(child_locations_table, request)
return {
'devices_table': devices_table,
'rack_count': rack_count,
'device_count': device_count,
'child_locations_table': child_locations_table,
}
@@ -1305,8 +1319,7 @@ class DeviceConsolePortsView(generic.ObjectView):
)
consoleport_table = tables.DeviceConsolePortTable(
data=consoleports,
user=request.user,
orderable=False
user=request.user
)
if request.user.has_perm('dcim.change_consoleport') or request.user.has_perm('dcim.delete_consoleport'):
consoleport_table.columns.show('pk')
@@ -1330,8 +1343,7 @@ class DeviceConsoleServerPortsView(generic.ObjectView):
)
consoleserverport_table = tables.DeviceConsoleServerPortTable(
data=consoleserverports,
user=request.user,
orderable=False
user=request.user
)
if request.user.has_perm('dcim.change_consoleserverport') or \
request.user.has_perm('dcim.delete_consoleserverport'):
@@ -1354,8 +1366,7 @@ class DevicePowerPortsView(generic.ObjectView):
)
powerport_table = tables.DevicePowerPortTable(
data=powerports,
user=request.user,
orderable=False
user=request.user
)
if request.user.has_perm('dcim.change_powerport') or request.user.has_perm('dcim.delete_powerport'):
powerport_table.columns.show('pk')
@@ -1377,8 +1388,7 @@ class DevicePowerOutletsView(generic.ObjectView):
)
poweroutlet_table = tables.DevicePowerOutletTable(
data=poweroutlets,
user=request.user,
orderable=False
user=request.user
)
if request.user.has_perm('dcim.change_poweroutlet') or request.user.has_perm('dcim.delete_poweroutlet'):
poweroutlet_table.columns.show('pk')
@@ -1395,15 +1405,14 @@ class DeviceInterfacesView(generic.ObjectView):
template_name = 'dcim/device/interfaces.html'
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('member_interfaces', queryset=Interface.objects.restrict(request.user)),
'lag', 'cable', '_path__destination', 'tags',
)
interface_table = tables.DeviceInterfaceTable(
data=interfaces,
user=request.user,
orderable=False
user=request.user
)
if request.user.has_perm('dcim.change_interface') or request.user.has_perm('dcim.delete_interface'):
interface_table.columns.show('pk')
@@ -1425,8 +1434,7 @@ class DeviceFrontPortsView(generic.ObjectView):
)
frontport_table = tables.DeviceFrontPortTable(
data=frontports,
user=request.user,
orderable=False
user=request.user
)
if request.user.has_perm('dcim.change_frontport') or request.user.has_perm('dcim.delete_frontport'):
frontport_table.columns.show('pk')
@@ -1446,8 +1454,7 @@ class DeviceRearPortsView(generic.ObjectView):
rearports = RearPort.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related('cable')
rearport_table = tables.DeviceRearPortTable(
data=rearports,
user=request.user,
orderable=False
user=request.user
)
if request.user.has_perm('dcim.change_rearport') or request.user.has_perm('dcim.delete_rearport'):
rearport_table.columns.show('pk')
@@ -1469,8 +1476,7 @@ class DeviceDeviceBaysView(generic.ObjectView):
)
devicebay_table = tables.DeviceDeviceBayTable(
data=devicebays,
user=request.user,
orderable=False
user=request.user
)
if request.user.has_perm('dcim.change_devicebay') or request.user.has_perm('dcim.delete_devicebay'):
devicebay_table.columns.show('pk')
@@ -1492,8 +1498,7 @@ class DeviceInventoryView(generic.ObjectView):
).prefetch_related('manufacturer')
inventoryitem_table = tables.DeviceInventoryItemTable(
data=inventoryitems,
user=request.user,
orderable=False
user=request.user
)
if request.user.has_perm('dcim.change_inventoryitem') or request.user.has_perm('dcim.delete_inventoryitem'):
inventoryitem_table.columns.show('pk')
@@ -1522,7 +1527,7 @@ class DeviceLLDPNeighborsView(generic.ObjectView):
template_name = 'dcim/device/lldp_neighbors.html'
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'
).exclude(
type__in=NONCONNECTABLE_IFACE_TYPES

View File

@@ -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):
content_types = ContentTypeFilter()
http_method = django_filters.MultipleChoiceFilter(
@@ -119,7 +140,7 @@ class ImageAttachmentFilterSet(BaseFilterSet):
fields = ['id', 'content_type_id', 'object_id', 'name']
class JournalEntryFilterSet(BaseFilterSet):
class JournalEntryFilterSet(BaseFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -150,7 +171,7 @@ class JournalEntryFilterSet(BaseFilterSet):
return queryset.filter(comments__icontains=value)
class TagFilterSet(BaseFilterSet):
class TagFilterSet(BaseFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -169,7 +190,7 @@ class TagFilterSet(BaseFilterSet):
)
class ConfigContextFilterSet(BaseFilterSet):
class ConfigContextFilterSet(BaseFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='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
#

View File

@@ -431,7 +431,9 @@ class JournalEntry(ChangeLoggedModel):
verbose_name_plural = 'journal entries'
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):
return reverse('extras:journalentry', args=[self.pk])

View File

@@ -116,7 +116,7 @@ class RouteTargetFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilt
fields = ['id', 'name']
class RIRFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
class RIRFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
class Meta:
model = RIR
@@ -173,7 +173,7 @@ class AggregateFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilter
return queryset.none()
class RoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
class RoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -515,7 +515,7 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilter
return queryset.none()
interface_ids = []
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(
interface__in=interface_ids
)
@@ -535,7 +535,7 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilter
return queryset.exclude(assigned_object_id__isnull=value)
class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
scope_type = ContentTypeFilter()
region = django_filters.NumberFilter(
method='filter_scope'

View File

@@ -1561,7 +1561,7 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm):
# Limit IP address choices to those assigned to interfaces of the parent device/VM
if self.instance.device:
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:
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(

View File

@@ -340,10 +340,10 @@ class IPAddressTable(BaseTable):
verbose_name='Interface'
)
assigned_object_parent = tables.Column(
accessor='assigned_object__parent',
accessor='assigned_object.parent_object',
linkify=True,
orderable=False,
verbose_name='Interface Parent'
verbose_name='Device/VM'
)
class Meta(BaseTable.Meta):

View File

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup
#
VERSION = '2.11.0'
VERSION = '2.11.2'
# Hostname
HOSTNAME = platform.node()

View File

@@ -2,7 +2,7 @@ $(document).ready(function() {
// Select or reset table columns
$('#save_tableconfig').click(function(event) {
$('select[name="columns"] option').attr("selected", "selected");
$('select[name="columns"] option').prop("selected", "selected");
});
$('#reset_tableconfig').click(function(event) {
$('select[name="columns"]').val([]);

View File

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

View File

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

View File

@@ -43,13 +43,13 @@
<tr>
<td>Racks</td>
<td>
<a href="{% url 'dcim:rack_list' %}?location_id={{ object.pk }}">{{ object.racks.count }}</a>
<a href="{% url 'dcim:rack_list' %}?location_id={{ object.pk }}">{{ rack_count }}</a>
</td>
</tr>
<tr>
<td>Devices</td>
<td>
<a href="{% url 'dcim:device_list' %}?location_id={{ object.pk }}">{{ devices_table.rows|length }}</a>
<a href="{% url 'dcim:device_list' %}?location_id={{ object.pk }}">{{ device_count }}</a>
</td>
</tr>
</table>
@@ -79,18 +79,18 @@
<div class="col-md-12">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Devices</strong>
<strong>Locations</strong>
</div>
{% include 'inc/table.html' with table=devices_table %}
{% if perms.dcim.add_device %}
{% include 'inc/table.html' with table=child_locations_table %}
{% if perms.dcim.add_location %}
<div class="panel-footer text-right noprint">
<a href="{% url 'dcim:device_add' %}?location={{ object.pk }}" class="btn btn-xs btn-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add device
<a href="{% url 'dcim:location_add' %}?site={{ object.site.pk }}&parent={{ object.pk }}" class="btn btn-xs btn-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add location
</a>
</div>
{% endif %}
</div>
{% include 'inc/paginator.html' with paginator=devices_table.paginator page=devices_table.page %}
{% include 'inc/paginator.html' with paginator=child_locations_table.paginator page=child_locations_table.page %}
{% plugin_full_width_page object %}
</div>
</div>

View File

@@ -2,6 +2,9 @@
<strong><a href="{{ device.get_absolute_url }}">{{ device }}</a></strong><br />
{{ device.device_type.manufacturer }} {{ device.device_type }}<br />
<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 %}
/ <a href="{{ device.rack.get_absolute_url }}">{{ device.rack }}</a>
{% endif %}

View File

@@ -76,7 +76,9 @@
</div>
</form>
{% else %}
{% render_table table 'inc/table.html' %}
<div class="table-responsive">
{% render_table table 'inc/table.html' %}
</div>
{% endif %}
{% endwith %}
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}

View File

@@ -9,6 +9,11 @@
<span class="caret"></span>
</button>
<ul class="dropdown-menu" aria-labelledby="max_length">
{% if request.GET.mask_length__lte %}
<li>
<a 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 %}
<li><a 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 %}

View File

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

View File

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

View File

@@ -376,6 +376,7 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
fieldsets = (
('Virtual Machine', ('name', 'role', 'status', 'tags')),
('Cluster', ('cluster_group', 'cluster')),
('Tenancy', ('tenant_group', 'tenant')),
('Management', ('platform', 'primary_ip4', 'primary_ip6')),
('Resources', ('vcpus', 'memory', 'disk')),
('Config Context', ('local_context_data',)),
@@ -682,7 +683,7 @@ class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm):
label='MAC Address'
)
description = forms.CharField(
max_length=100,
max_length=200,
required=False
)
mode = forms.ChoiceField(

View File

@@ -0,0 +1,32 @@
from django.db import migrations
import utilities.fields
import utilities.ordering
def naturalize_virtualmachines(apps, schema_editor):
VirtualMachine = apps.get_model('virtualization', 'VirtualMachine')
for name in VirtualMachine.objects.values_list('name', flat=True).order_by('name').distinct():
VirtualMachine.objects.filter(name=name).update(_name=utilities.ordering.naturalize(name, max_length=100))
class Migration(migrations.Migration):
dependencies = [
('virtualization', '0022_vminterface_parent'),
]
operations = [
migrations.AlterModelOptions(
name='virtualmachine',
options={'ordering': ('_name', 'pk')},
),
migrations.AddField(
model_name='virtualmachine',
name='_name',
field=utilities.fields.NaturalOrderingField('name', max_length=100, blank=True, naturalize_function=utilities.ordering.naturalize),
),
migrations.RunPython(
code=naturalize_virtualmachines,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -226,6 +226,11 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
name = models.CharField(
max_length=64
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
status = models.CharField(
max_length=50,
choices=VirtualMachineStatusChoices,
@@ -296,7 +301,7 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
]
class Meta:
ordering = ('name', 'pk') # Name may be non-unique
ordering = ('_name', 'pk') # Name may be non-unique
unique_together = [
['cluster', 'tenant', 'name']
]

View File

@@ -8,7 +8,7 @@ django-pglocks==1.0.4
django-prometheus==2.1.0
django-rq==2.4.1
django-tables2==2.3.4
django-taggit==1.3.0
django-taggit==1.4.0
django-timezone-field==4.1.2
djangorestframework==3.12.4
drf-yasg[validation]==1.20.0