diff --git a/CHANGELOG.md b/CHANGELOG.md index 52230def4..6e6c3d361 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,10 +31,15 @@ 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 +* [#2961](https://github.com/digitalocean/netbox/issues/2961) - Prevent exception when exporting inventory items belonging to unnamed devices +* [#2962](https://github.com/digitalocean/netbox/issues/2962) - Increase ExportTemplate `mime_type` field length +* [#2966](https://github.com/digitalocean/netbox/issues/2966) - Accept `null` cable length_unit via API --- diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 82bd9b6be..969308ba1 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -507,7 +507,7 @@ class CableSerializer(ValidatedModelSerializer): termination_a = serializers.SerializerMethodField(read_only=True) termination_b = serializers.SerializerMethodField(read_only=True) status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, required=False) - length_unit = ChoiceField(choices=CABLE_LENGTH_UNIT_CHOICES, required=False) + length_unit = ChoiceField(choices=CABLE_LENGTH_UNIT_CHOICES, required=False, allow_null=True) class Meta: model = Cable diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 1307d6155..2e303e325 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.models import Tenant 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(CustomFieldFilterSet, django_filters.FilterSet): 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)', ) tenant_id = django_filters.ModelMultipleChoiceFilter( @@ -95,16 +97,6 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): 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( @@ -513,14 +505,15 @@ class DeviceFilter(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( @@ -619,16 +612,6 @@ class DeviceFilter(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/models.py b/netbox/dcim/models.py index 541fd646e..9d5bd2dfc 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -2416,7 +2416,7 @@ class InventoryItem(ComponentModel): def to_csv(self): return ( - self.device.name or '{' + self.device.pk + '}', + self.device.name or '{{{}}}'.format(self.device.pk), self.name, self.manufacturer.name if self.manufacturer else None, self.part_id, diff --git a/netbox/extras/migrations/0017_exporttemplate_mime_type_length.py b/netbox/extras/migrations/0017_exporttemplate_mime_type_length.py new file mode 100644 index 000000000..29283e0d1 --- /dev/null +++ b/netbox/extras/migrations/0017_exporttemplate_mime_type_length.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.7 on 2019-03-05 18:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0016_exporttemplate_add_cable'), + ] + + operations = [ + migrations.AlterField( + model_name='exporttemplate', + name='mime_type', + field=models.CharField(blank=True, max_length=50), + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 9d907c84e..ccdcb51ab 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -359,7 +359,7 @@ class ExportTemplate(models.Model): ) template_code = models.TextField() mime_type = models.CharField( - max_length=15, + max_length=50, blank=True ) file_extension = models.CharField( diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 2cf1e1f0a..08ded2b63 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/utilities/filters.py b/netbox/utilities/filters.py index 674aee639..6e2116751 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -5,6 +5,15 @@ from django.db.models import Q from extras.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 0b7e57ba7..0e5ff6cd2 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -7,7 +7,7 @@ from netaddr.core import AddrFormatError from dcim.models import DeviceRole, Interface, Platform, Region, Site from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant -from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter +from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter from .constants import VM_STATUS_CHOICES from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -119,14 +119,15 @@ class VirtualMachineFilter(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( @@ -184,16 +185,6 @@ class VirtualMachineFilter(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(