From a311002141708ac6e1d987e7f35e467b9d9434e1 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Sun, 9 Feb 2020 03:20:59 -0500 Subject: [PATCH 01/61] initial work on dynamic lookup expressions --- netbox/circuits/filters.py | 12 +++-- netbox/dcim/filters.py | 44 ++++++++--------- netbox/extras/filters.py | 13 ++--- netbox/tenancy/filters.py | 2 +- netbox/utilities/constants.py | 38 +++++++++++++++ netbox/utilities/filters.py | 91 +++++++++++++++++++++++++++++++++++ 6 files changed, 166 insertions(+), 34 deletions(-) diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index c27ffb8d7..b0c4cacbd 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -4,7 +4,9 @@ from django.db.models import Q from dcim.models import Region, Site from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet from tenancy.filters import TenancyFilterSet -from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter +from utilities.filters import ( + BaseFilterSet, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter +) from .choices import * from .models import Circuit, CircuitTermination, CircuitType, Provider @@ -16,7 +18,7 @@ __all__ = ( ) -class ProviderFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): +class ProviderFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -65,14 +67,14 @@ class ProviderFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): ) -class CircuitTypeFilterSet(NameSlugSearchFilterSet): +class CircuitTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = CircuitType fields = ['id', 'name', 'slug'] -class CircuitFilterSet(CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet): +class CircuitFilterSet(BaseFilterSet, CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -146,7 +148,7 @@ class CircuitFilterSet(CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFil ).distinct() -class CircuitTerminationFilterSet(django_filters.FilterSet): +class CircuitTerminationFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 7b278ca0e..d9658a28e 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -6,8 +6,8 @@ from tenancy.filters import TenancyFilterSet from tenancy.models import Tenant from utilities.constants import COLOR_CHOICES from utilities.filters import ( - MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericInFilter, - TagFilter, TreeNodeMultipleChoiceFilter, + BaseFilterSet, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, + BaseFilterSet, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter, ) from virtualization.models import Cluster from .choices import * @@ -60,7 +60,7 @@ __all__ = ( ) -class RegionFilterSet(NameSlugSearchFilterSet): +class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=Region.objects.all(), label='Parent region (ID)', @@ -77,7 +77,7 @@ class RegionFilterSet(NameSlugSearchFilterSet): fields = ['id', 'name', 'slug'] -class SiteFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class SiteFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -131,7 +131,7 @@ class SiteFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilter return queryset.filter(qs_filter) -class RackGroupFilterSet(NameSlugSearchFilterSet): +class RackGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region__in', @@ -159,14 +159,14 @@ class RackGroupFilterSet(NameSlugSearchFilterSet): fields = ['id', 'name', 'slug'] -class RackRoleFilterSet(NameSlugSearchFilterSet): +class RackRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = RackRole fields = ['id', 'name', 'slug', 'color'] -class RackFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class RackFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -244,7 +244,7 @@ class RackFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilter ) -class RackReservationFilterSet(TenancyFilterSet): +class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -305,14 +305,14 @@ class RackReservationFilterSet(TenancyFilterSet): ) -class ManufacturerFilterSet(NameSlugSearchFilterSet): +class ManufacturerFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = Manufacturer fields = ['id', 'name', 'slug'] -class DeviceTypeFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): +class DeviceTypeFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -402,7 +402,7 @@ class DeviceTypeFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): return queryset.exclude(device_bay_templates__isnull=value) -class DeviceTypeComponentFilterSet(NameSlugSearchFilterSet): +class DeviceTypeComponentFilterSet(BaseFilterSet, NameSlugSearchFilterSet): devicetype_id = django_filters.ModelMultipleChoiceFilter( queryset=DeviceType.objects.all(), field_name='device_type_id', @@ -466,14 +466,14 @@ class DeviceBayTemplateFilterSet(DeviceTypeComponentFilterSet): fields = ['id', 'name'] -class DeviceRoleFilterSet(NameSlugSearchFilterSet): +class DeviceRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = DeviceRole fields = ['id', 'name', 'slug', 'color', 'vm_role'] -class PlatformFilterSet(NameSlugSearchFilterSet): +class PlatformFilterSet(BaseFilterSet, NameSlugSearchFilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( field_name='manufacturer', queryset=Manufacturer.objects.all(), @@ -491,7 +491,7 @@ class PlatformFilterSet(NameSlugSearchFilterSet): fields = ['id', 'name', 'slug', 'napalm_driver'] -class DeviceFilterSet(LocalConfigContextFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class DeviceFilterSet(BaseFilterSet, LocalConfigContextFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -690,7 +690,7 @@ class DeviceFilterSet(LocalConfigContextFilterSet, TenancyFilterSet, CustomField return queryset.exclude(device_bays__isnull=value) -class DeviceComponentFilterSet(django_filters.FilterSet): +class DeviceComponentFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1002,7 +1002,7 @@ class InventoryItemFilterSet(DeviceComponentFilterSet): return queryset.filter(qs_filter) -class VirtualChassisFilterSet(django_filters.FilterSet): +class VirtualChassisFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1056,7 +1056,7 @@ class VirtualChassisFilterSet(django_filters.FilterSet): return queryset.filter(qs_filter) -class CableFilterSet(django_filters.FilterSet): +class CableFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1119,7 +1119,7 @@ class CableFilterSet(django_filters.FilterSet): return queryset -class ConsoleConnectionFilterSet(django_filters.FilterSet): +class ConsoleConnectionFilterSet(BaseFilterSet): site = django_filters.CharFilter( method='filter_site', label='Site (slug)', @@ -1150,7 +1150,7 @@ class ConsoleConnectionFilterSet(django_filters.FilterSet): ) -class PowerConnectionFilterSet(django_filters.FilterSet): +class PowerConnectionFilterSet(BaseFilterSet): site = django_filters.CharFilter( method='filter_site', label='Site (slug)', @@ -1181,7 +1181,7 @@ class PowerConnectionFilterSet(django_filters.FilterSet): ) -class InterfaceConnectionFilterSet(django_filters.FilterSet): +class InterfaceConnectionFilterSet(BaseFilterSet): site = django_filters.CharFilter( method='filter_site', label='Site (slug)', @@ -1215,7 +1215,7 @@ class InterfaceConnectionFilterSet(django_filters.FilterSet): ) -class PowerPanelFilterSet(django_filters.FilterSet): +class PowerPanelFilterSet(BaseFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -1264,7 +1264,7 @@ class PowerPanelFilterSet(django_filters.FilterSet): return queryset.filter(qs_filter) -class PowerFeedFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): +class PowerFeedFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index dcd4f3ede..ccd723a0c 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -4,6 +4,7 @@ from django.db.models import Q from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup +from utilities.filters import BaseFilterSet from virtualization.models import Cluster, ClusterGroup from .choices import * from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag @@ -89,21 +90,21 @@ class CustomFieldFilterSet(django_filters.FilterSet): self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(field_name=cf.name, custom_field=cf) -class GraphFilterSet(django_filters.FilterSet): +class GraphFilterSet(BaseFilterSet): class Meta: model = Graph fields = ['type', 'name', 'template_language'] -class ExportTemplateFilterSet(django_filters.FilterSet): +class ExportTemplateFilterSet(BaseFilterSet): class Meta: model = ExportTemplate fields = ['content_type', 'name', 'template_language'] -class TagFilterSet(django_filters.FilterSet): +class TagFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -122,7 +123,7 @@ class TagFilterSet(django_filters.FilterSet): ) -class ConfigContextFilterSet(django_filters.FilterSet): +class ConfigContextFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -234,7 +235,7 @@ class ConfigContextFilterSet(django_filters.FilterSet): # Filter for Local Config Context Data # -class LocalConfigContextFilterSet(django_filters.FilterSet): +class LocalConfigContextFilterSet(BaseFilterSet): local_context_data = django_filters.BooleanFilter( method='_local_context_data', label='Has local config context data', @@ -244,7 +245,7 @@ class LocalConfigContextFilterSet(django_filters.FilterSet): return queryset.exclude(local_context_data__isnull=value) -class ObjectChangeFilterSet(django_filters.FilterSet): +class ObjectChangeFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py index 001cf29e7..fa40e3986 100644 --- a/netbox/tenancy/filters.py +++ b/netbox/tenancy/filters.py @@ -2,7 +2,7 @@ import django_filters from django.db.models import Q from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet -from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter +from utilities.filters import BaseFilterSet, NameSlugSearchFilterSet, NumericInFilter, TagFilter from .models import Tenant, TenantGroup diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py index ad6e8fd90..d7f819e8c 100644 --- a/netbox/utilities/constants.py +++ b/netbox/utilities/constants.py @@ -27,3 +27,41 @@ COLOR_CHOICES = ( ('111111', 'Black'), ('ffffff', 'White'), ) + + +# +# Filter lookup expressions +# + +FILTER_CHAR_BASED_LOOKUP_MAP = dict( + n='exact', + ic='icontains', + nic='icontains', + iew='iendswith', + niew='iendswith', + isw='istartswith', + nisw='istartswith', + ie='iexact', + nie='iexact' +) + +FILTER_NUMERIC_BASED_LOOKUP_MAP = dict( + n='exact', + lte='lte', + lt='lt', + gte='gte', + gt='gt' +) + +FILTER_LOOKUP_HELP_TEXT_MAP = dict( + icontains='case insensitive contains', + iendswith='case insensitive ends with', + istartswith='case insensitive starts with', + iexact='case insensitive exact', + exact='case sensitive exact', + lt='less than', + lte='less than or equal', + gt='greater than', + gte='greater than or equal', + n='negated' +) diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 957020e40..edb7bafa0 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -3,7 +3,12 @@ from dcim.forms import MACAddressField from django import forms from django.conf import settings from django.db import models +from django_filters.utils import get_model_field + from extras.models import Tag +from utilities.constants import ( + FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_LOOKUP_HELP_TEXT_MAP, FILTER_NUMERIC_BASED_LOOKUP_MAP +) def multivalue_field_factory(field_class): @@ -111,6 +116,92 @@ class TagFilter(django_filters.ModelMultipleChoiceFilter): # FilterSets # +class BaseFilterSet(django_filters.FilterSet): + """ + A base filterset which provides common functionaly to all NetBox filtersets + """ + @classmethod + def get_filters(cls): + """ + Override filter generation to support dynamic lookup expressions for certain filter types. + + For specific filter types, new filters are created based on defined lookup expressions in + the form `_` + """ + filters = super().get_filters() + + new_filters = {} + for existing_filter_name, existing_filter in filters.items(): + # Loop over existing filters to extract metadata by which to create new filters + + # It the filter makes use of a custom filter method or lookup expression skip it + # as we cannot sanely handle these cases in a generic mannor + if existing_filter.method is not None or existing_filter.lookup_expr != 'exact': + continue + + # Choose the lookup expression map based on the filter type + if isinstance(existing_filter, ( + django_filters.filters.CharFilter, + django_filters.MultipleChoiceFilter, + MultiValueCharFilter, + MultiValueMACAddressFilter, + TagFilter + )): + lookup_map = FILTER_CHAR_BASED_LOOKUP_MAP + + elif isinstance(existing_filter, ( + MultiValueDateFilter, + MultiValueDateTimeFilter, + MultiValueNumberFilter, + MultiValueTimeFilter + )): + lookup_map = FILTER_NUMERIC_BASED_LOOKUP_MAP + + elif isinstance(existing_filter, ( + django_filters.ModelChoiceFilter, + django_filters.ModelMultipleChoiceFilter, + NumericInFilter, + TreeNodeMultipleChoiceFilter, + )): + # These filter types support only negation + lookup_map = dict( + n='exact' + ) + + else: + # Do no augment any other filter types with more lookup expressions + continue + + # Get properties of the existing filter for later use + field_name = existing_filter.field_name + field = get_model_field(cls._meta.model, field_name) + + # Create new filters for each lookup expression in the map + for lookup_name, lookup_expr in lookup_map.items(): + new_filter_name = '{}_{}'.format(existing_filter_name, lookup_name) + + try: + print(existing_filter_name) + new_filter = cls.filter_for_field(field, field_name, lookup_expr) + except django_filters.exceptions.FieldLookupError: + # The filter could not be created because the lookup expression is not supported + continue + + help_text = FILTER_LOOKUP_HELP_TEXT_MAP[lookup_expr] + + if lookup_name.startswith('n'): + # This is a negation filter which requires a queryselt.exclud() clause + new_filter.exclude = True + help_text = 'negated {}'.format(help_text) + + new_filter.extra = existing_filter.extra + new_filter.extra['help_text'] = '{} - {}'.format(field_name, help_text) + new_filters[new_filter_name] = new_filter + + filters.update(new_filters) + return filters + + class NameSlugSearchFilterSet(django_filters.FilterSet): """ A base class for adding the search method to models which only expose the `name` and `slug` fields From a6b43b30e9f59f30a3365fb6a0e80a6edb4954af Mon Sep 17 00:00:00 2001 From: John Anderson Date: Sun, 9 Feb 2020 17:46:21 -0500 Subject: [PATCH 02/61] functional dynamic filter lookups --- netbox/circuits/filters.py | 12 ++- netbox/dcim/filters.py | 94 +++++++++++-------- netbox/extras/filters.py | 2 +- netbox/extras/tests/test_filters.py | 12 +-- netbox/ipam/filters.py | 39 ++++---- netbox/secrets/filters.py | 6 +- netbox/tenancy/filters.py | 4 +- netbox/utilities/filters.py | 134 ++++++++++++++-------------- netbox/virtualization/filters.py | 24 +++-- 9 files changed, 183 insertions(+), 144 deletions(-) diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index b0c4cacbd..4bd5fa158 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -29,12 +29,14 @@ class ProviderFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilte ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='circuits__terminations__site__region__in', + field_name='circuits__terminations__site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='circuits__terminations__site__region__in', + field_name='circuits__terminations__site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) @@ -120,12 +122,14 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldFilterSet, TenancyFilterSet, Cr ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='terminations__site__region__in', + field_name='terminations__site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='terminations__site__region__in', + field_name='terminations__site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index d9658a28e..0d7c440c2 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -7,7 +7,7 @@ from tenancy.models import Tenant from utilities.constants import COLOR_CHOICES from utilities.filters import ( BaseFilterSet, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, - BaseFilterSet, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter, + NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter, ) from virtualization.models import Cluster from .choices import * @@ -92,12 +92,14 @@ class SiteFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='region__in', + field_name='region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='region__in', + field_name='region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) @@ -134,12 +136,14 @@ class SiteFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet class RackGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) @@ -177,12 +181,14 @@ class RackFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) @@ -402,7 +408,7 @@ class DeviceTypeFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFil return queryset.exclude(device_bay_templates__isnull=value) -class DeviceTypeComponentFilterSet(BaseFilterSet, NameSlugSearchFilterSet): +class DeviceTypeComponentFilterSet(NameSlugSearchFilterSet): devicetype_id = django_filters.ModelMultipleChoiceFilter( queryset=DeviceType.objects.all(), field_name='device_type_id', @@ -410,56 +416,56 @@ class DeviceTypeComponentFilterSet(BaseFilterSet, NameSlugSearchFilterSet): ) -class ConsolePortTemplateFilterSet(DeviceTypeComponentFilterSet): +class ConsolePortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): class Meta: model = ConsolePortTemplate fields = ['id', 'name', 'type'] -class ConsoleServerPortTemplateFilterSet(DeviceTypeComponentFilterSet): +class ConsoleServerPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): class Meta: model = ConsoleServerPortTemplate fields = ['id', 'name', 'type'] -class PowerPortTemplateFilterSet(DeviceTypeComponentFilterSet): +class PowerPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): class Meta: model = PowerPortTemplate fields = ['id', 'name', 'type', 'maximum_draw', 'allocated_draw'] -class PowerOutletTemplateFilterSet(DeviceTypeComponentFilterSet): +class PowerOutletTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): class Meta: model = PowerOutletTemplate fields = ['id', 'name', 'type', 'feed_leg'] -class InterfaceTemplateFilterSet(DeviceTypeComponentFilterSet): +class InterfaceTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): class Meta: model = InterfaceTemplate fields = ['id', 'name', 'type', 'mgmt_only'] -class FrontPortTemplateFilterSet(DeviceTypeComponentFilterSet): +class FrontPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): class Meta: model = FrontPortTemplate fields = ['id', 'name', 'type'] -class RearPortTemplateFilterSet(DeviceTypeComponentFilterSet): +class RearPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): class Meta: model = RearPortTemplate fields = ['id', 'name', 'type', 'positions'] -class DeviceBayTemplateFilterSet(DeviceTypeComponentFilterSet): +class DeviceBayTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): class Meta: model = DeviceBayTemplate @@ -538,12 +544,14 @@ class DeviceFilterSet(BaseFilterSet, LocalConfigContextFilterSet, CustomFieldFil ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) @@ -690,19 +698,21 @@ class DeviceFilterSet(BaseFilterSet, LocalConfigContextFilterSet, CustomFieldFil return queryset.exclude(device_bays__isnull=value) -class DeviceComponentFilterSet(BaseFilterSet): +class DeviceComponentFilterSet(django_filters.FilterSet): q = django_filters.CharFilter( method='search', label='Search', ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='device__site__region__in', + field_name='device__site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='device__site__region__in', + field_name='device__site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) @@ -738,7 +748,7 @@ class DeviceComponentFilterSet(BaseFilterSet): ) -class ConsolePortFilterSet(DeviceComponentFilterSet): +class ConsolePortFilterSet(BaseFilterSet, DeviceComponentFilterSet): type = django_filters.MultipleChoiceFilter( choices=ConsolePortTypeChoices, null_value=None @@ -754,7 +764,7 @@ class ConsolePortFilterSet(DeviceComponentFilterSet): fields = ['id', 'name', 'description', 'connection_status'] -class ConsoleServerPortFilterSet(DeviceComponentFilterSet): +class ConsoleServerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet): type = django_filters.MultipleChoiceFilter( choices=ConsolePortTypeChoices, null_value=None @@ -770,7 +780,7 @@ class ConsoleServerPortFilterSet(DeviceComponentFilterSet): fields = ['id', 'name', 'description', 'connection_status'] -class PowerPortFilterSet(DeviceComponentFilterSet): +class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet): type = django_filters.MultipleChoiceFilter( choices=PowerPortTypeChoices, null_value=None @@ -786,7 +796,7 @@ class PowerPortFilterSet(DeviceComponentFilterSet): fields = ['id', 'name', 'maximum_draw', 'allocated_draw', 'description', 'connection_status'] -class PowerOutletFilterSet(DeviceComponentFilterSet): +class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet): type = django_filters.MultipleChoiceFilter( choices=PowerOutletTypeChoices, null_value=None @@ -802,7 +812,7 @@ class PowerOutletFilterSet(DeviceComponentFilterSet): fields = ['id', 'name', 'feed_leg', 'description', 'connection_status'] -class InterfaceFilterSet(DeviceComponentFilterSet): +class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -900,7 +910,7 @@ class InterfaceFilterSet(DeviceComponentFilterSet): }.get(value, queryset.none()) -class FrontPortFilterSet(DeviceComponentFilterSet): +class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet): cabled = django_filters.BooleanFilter( field_name='cable', lookup_expr='isnull', @@ -912,7 +922,7 @@ class FrontPortFilterSet(DeviceComponentFilterSet): fields = ['id', 'name', 'type', 'description'] -class RearPortFilterSet(DeviceComponentFilterSet): +class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet): cabled = django_filters.BooleanFilter( field_name='cable', lookup_expr='isnull', @@ -924,26 +934,28 @@ class RearPortFilterSet(DeviceComponentFilterSet): fields = ['id', 'name', 'type', 'positions', 'description'] -class DeviceBayFilterSet(DeviceComponentFilterSet): +class DeviceBayFilterSet(BaseFilterSet, DeviceComponentFilterSet): class Meta: model = DeviceBay fields = ['id', 'name', 'description'] -class InventoryItemFilterSet(DeviceComponentFilterSet): +class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet): q = django_filters.CharFilter( method='search', label='Search', ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='device__site__region__in', + field_name='device__site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='device__site__region__in', + field_name='device__site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) @@ -1009,12 +1021,14 @@ class VirtualChassisFilterSet(BaseFilterSet): ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='master__site__region__in', + field_name='master__site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='master__site__region__in', + field_name='master__site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) @@ -1226,12 +1240,14 @@ class PowerPanelFilterSet(BaseFilterSet): ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) @@ -1275,12 +1291,14 @@ class PowerFeedFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilt ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='power_panel__site__region__in', + field_name='power_panel__site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='power_panel__site__region__in', + field_name='power_panel__site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index ccd723a0c..ad414a691 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -235,7 +235,7 @@ class ConfigContextFilterSet(BaseFilterSet): # Filter for Local Config Context Data # -class LocalConfigContextFilterSet(BaseFilterSet): +class LocalConfigContextFilterSet(django_filters.FilterSet): local_context_data = django_filters.BooleanFilter( method='_local_context_data', label='Has local config context data', diff --git a/netbox/extras/tests/test_filters.py b/netbox/extras/tests/test_filters.py index 5ef96faa2..ab559cf73 100644 --- a/netbox/extras/tests/test_filters.py +++ b/netbox/extras/tests/test_filters.py @@ -28,8 +28,8 @@ class GraphTestCase(TestCase): Graph.objects.bulk_create(graphs) def test_name(self): - params = {'name': 'Graph 1'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'name': ['Graph 1', 'Graph 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_type(self): content_type = ContentType.objects.filter(GRAPH_MODELS).first() @@ -59,8 +59,8 @@ class ExportTemplateTestCase(TestCase): ExportTemplate.objects.bulk_create(export_templates) def test_name(self): - params = {'name': 'Export Template 1'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'name': ['Export Template 1', 'Export Template 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_content_type(self): params = {'content_type': ContentType.objects.get(model='site').pk} @@ -154,8 +154,8 @@ class ConfigContextTestCase(TestCase): c.tenants.set([tenants[i]]) def test_name(self): - params = {'name': 'Config Context 1'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'name': ['Config Context 1', 'Config Context 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_is_active(self): params = {'is_active': True} diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 67ad769cc..f0b3275f0 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -8,7 +8,8 @@ from dcim.models import Device, Interface, Region, Site from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet from tenancy.filters import TenancyFilterSet from utilities.filters import ( - MultiValueCharFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter, + BaseFilterSet, MultiValueCharFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, + TreeNodeMultipleChoiceFilter, ) from virtualization.models import VirtualMachine from .choices import * @@ -28,7 +29,7 @@ __all__ = ( ) -class VRFFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class VRFFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -53,7 +54,7 @@ class VRFFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterS fields = ['name', 'rd', 'enforce_unique'] -class RIRFilterSet(NameSlugSearchFilterSet): +class RIRFilterSet(BaseFilterSet, NameSlugSearchFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -64,7 +65,7 @@ class RIRFilterSet(NameSlugSearchFilterSet): fields = ['name', 'slug', 'is_private'] -class AggregateFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): +class AggregateFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -114,7 +115,7 @@ class AggregateFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): return queryset.none() -class RoleFilterSet(NameSlugSearchFilterSet): +class RoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -125,7 +126,7 @@ class RoleFilterSet(NameSlugSearchFilterSet): fields = ['id', 'name', 'slug'] -class PrefixFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class PrefixFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -166,12 +167,14 @@ class PrefixFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilt ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) @@ -273,7 +276,7 @@ class PrefixFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilt return queryset.filter(prefix__net_mask_length=value) -class IPAddressFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -395,15 +398,17 @@ class IPAddressFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedF return queryset.exclude(interface__isnull=value) -class VLANGroupFilterSet(NameSlugSearchFilterSet): +class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) @@ -423,7 +428,7 @@ class VLANGroupFilterSet(NameSlugSearchFilterSet): fields = ['id', 'name', 'slug'] -class VLANFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class VLANFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -434,12 +439,14 @@ class VLANFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilter ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) @@ -494,7 +501,7 @@ class VLANFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilter return queryset.filter(qs_filter) -class ServiceFilterSet(CreatedUpdatedFilterSet): +class ServiceFilterSet(BaseFilterSet, CreatedUpdatedFilterSet): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/secrets/filters.py b/netbox/secrets/filters.py index 0c2b01f4d..f32ac1c55 100644 --- a/netbox/secrets/filters.py +++ b/netbox/secrets/filters.py @@ -3,7 +3,7 @@ from django.db.models import Q from dcim.models import Device from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet -from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter +from utilities.filters import BaseFilterSet, NameSlugSearchFilterSet, NumericInFilter, TagFilter from .models import Secret, SecretRole @@ -13,14 +13,14 @@ __all__ = ( ) -class SecretRoleFilterSet(NameSlugSearchFilterSet): +class SecretRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = SecretRole fields = ['id', 'name', 'slug'] -class SecretFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): +class SecretFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py index fa40e3986..8ba3054aa 100644 --- a/netbox/tenancy/filters.py +++ b/netbox/tenancy/filters.py @@ -13,14 +13,14 @@ __all__ = ( ) -class TenantGroupFilterSet(NameSlugSearchFilterSet): +class TenantGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = TenantGroup fields = ['id', 'name', 'slug'] -class TenantFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): +class TenantFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index edb7bafa0..9dacc55eb 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -1,9 +1,10 @@ import django_filters +from copy import deepcopy from dcim.forms import MACAddressField from django import forms from django.conf import settings from django.db import models -from django_filters.utils import get_model_field +from django_filters.utils import get_model_field, resolve_field from extras.models import Tag from utilities.constants import ( @@ -120,13 +121,62 @@ class BaseFilterSet(django_filters.FilterSet): """ A base filterset which provides common functionaly to all NetBox filtersets """ + FILTER_DEFAULTS = deepcopy(django_filters.filterset.FILTER_FOR_DBFIELD_DEFAULTS) + FILTER_DEFAULTS.update({ + models.AutoField: { + 'filter_class': MultiValueNumberFilter + }, + models.CharField: { + 'filter_class': MultiValueCharFilter + }, + models.DateField: { + 'filter_class': MultiValueDateFilter + }, + models.DateTimeField: { + 'filter_class': MultiValueDateTimeFilter + }, + models.DecimalField: { + 'filter_class': MultiValueNumberFilter + }, + models.EmailField: { + 'filter_class': MultiValueCharFilter + }, + models.FloatField: { + 'filter_class': MultiValueNumberFilter + }, + models.IntegerField: { + 'filter_class': MultiValueNumberFilter + }, + models.PositiveIntegerField: { + 'filter_class': MultiValueNumberFilter + }, + models.PositiveSmallIntegerField: { + 'filter_class': MultiValueNumberFilter + }, + models.SlugField: { + 'filter_class': MultiValueCharFilter + }, + models.SmallIntegerField: { + 'filter_class': MultiValueNumberFilter + }, + models.TimeField: { + 'filter_class': MultiValueTimeFilter + }, + models.URLField: { + 'filter_class': MultiValueCharFilter + }, + MACAddressField: { + 'filter_class': MultiValueMACAddressFilter + }, + }) + @classmethod def get_filters(cls): """ Override filter generation to support dynamic lookup expressions for certain filter types. For specific filter types, new filters are created based on defined lookup expressions in - the form `_` + the form `__` """ filters = super().get_filters() @@ -136,7 +186,7 @@ class BaseFilterSet(django_filters.FilterSet): # It the filter makes use of a custom filter method or lookup expression skip it # as we cannot sanely handle these cases in a generic mannor - if existing_filter.method is not None or existing_filter.lookup_expr != 'exact': + if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'in']: continue # Choose the lookup expression map based on the filter type @@ -169,7 +219,7 @@ class BaseFilterSet(django_filters.FilterSet): ) else: - # Do no augment any other filter types with more lookup expressions + # Do not augment any other filter types with more lookup expressions continue # Get properties of the existing filter for later use @@ -178,24 +228,29 @@ class BaseFilterSet(django_filters.FilterSet): # Create new filters for each lookup expression in the map for lookup_name, lookup_expr in lookup_map.items(): - new_filter_name = '{}_{}'.format(existing_filter_name, lookup_name) - + new_filter_name = '{}__{}'.format(existing_filter_name, lookup_name) + try: - print(existing_filter_name) - new_filter = cls.filter_for_field(field, field_name, lookup_expr) + if existing_filter_name in cls.declared_filters: + resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid + new_filter = type(existing_filter)( + field_name=field_name, + lookup_expr=lookup_expr, + label=existing_filter.label, + exclude=existing_filter.exclude, + distinct=existing_filter.distinct, + **existing_filter.extra + ) + else: + new_filter = cls.filter_for_field(field, field_name, lookup_expr) except django_filters.exceptions.FieldLookupError: - # The filter could not be created because the lookup expression is not supported + # The filter could not be created because the lookup expression is not supported on the field continue - help_text = FILTER_LOOKUP_HELP_TEXT_MAP[lookup_expr] - if lookup_name.startswith('n'): # This is a negation filter which requires a queryselt.exclud() clause new_filter.exclude = True - help_text = 'negated {}'.format(help_text) - new_filter.extra = existing_filter.extra - new_filter.extra['help_text'] = '{} - {}'.format(field_name, help_text) new_filters[new_filter_name] = new_filter filters.update(new_filters) @@ -218,54 +273,3 @@ class NameSlugSearchFilterSet(django_filters.FilterSet): models.Q(name__icontains=value) | models.Q(slug__icontains=value) ) - - -# -# Update default filters -# - -FILTER_DEFAULTS = django_filters.filterset.FILTER_FOR_DBFIELD_DEFAULTS -FILTER_DEFAULTS.update({ - models.AutoField: { - 'filter_class': MultiValueNumberFilter - }, - models.CharField: { - 'filter_class': MultiValueCharFilter - }, - models.DateField: { - 'filter_class': MultiValueDateFilter - }, - models.DateTimeField: { - 'filter_class': MultiValueDateTimeFilter - }, - models.DecimalField: { - 'filter_class': MultiValueNumberFilter - }, - models.EmailField: { - 'filter_class': MultiValueCharFilter - }, - models.FloatField: { - 'filter_class': MultiValueNumberFilter - }, - models.IntegerField: { - 'filter_class': MultiValueNumberFilter - }, - models.PositiveIntegerField: { - 'filter_class': MultiValueNumberFilter - }, - models.PositiveSmallIntegerField: { - 'filter_class': MultiValueNumberFilter - }, - models.SlugField: { - 'filter_class': MultiValueCharFilter - }, - models.SmallIntegerField: { - 'filter_class': MultiValueNumberFilter - }, - models.TimeField: { - 'filter_class': MultiValueTimeFilter - }, - models.URLField: { - 'filter_class': MultiValueCharFilter - }, -}) diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index 0d817ef47..d5af9e3d4 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -6,7 +6,8 @@ from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet, LocalC from tenancy.filters import TenancyFilterSet from tenancy.models import Tenant from utilities.filters import ( - MultiValueMACAddressFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter, + BaseFilterSet, MultiValueMACAddressFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, + TreeNodeMultipleChoiceFilter, ) from .choices import * from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -20,21 +21,21 @@ __all__ = ( ) -class ClusterTypeFilterSet(NameSlugSearchFilterSet): +class ClusterTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = ClusterType fields = ['id', 'name', 'slug'] -class ClusterGroupFilterSet(NameSlugSearchFilterSet): +class ClusterGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = ClusterGroup fields = ['id', 'name', 'slug'] -class ClusterFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): +class ClusterFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -45,12 +46,14 @@ class ClusterFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) @@ -104,6 +107,7 @@ class ClusterFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): class VirtualMachineFilterSet( + BaseFilterSet, LocalConfigContextFilterSet, TenancyFilterSet, CustomFieldFilterSet, @@ -149,12 +153,14 @@ class VirtualMachineFilterSet( ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='cluster__site__region__in', + field_name='cluster__site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='cluster__site__region__in', + field_name='cluster__site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) @@ -208,7 +214,7 @@ class VirtualMachineFilterSet( ) -class InterfaceFilterSet(django_filters.FilterSet): +class InterfaceFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', label='Search', From 9284e8327025a1df129768f46da4945d468a0ff5 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Sun, 9 Feb 2020 21:32:45 -0500 Subject: [PATCH 03/61] py3.5 compatibility --- netbox/utilities/filters.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 9dacc55eb..d1364cf4b 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -178,13 +178,15 @@ class BaseFilterSet(django_filters.FilterSet): For specific filter types, new filters are created based on defined lookup expressions in the form `__` """ - filters = super().get_filters() + # TODO: once 3.6 is the minimum required version of python, change this to a bare super() call + # We have to do it this way in py3.5 becuase of django_filters.FilterSet's use of a metaclass + filters = super(django_filters.FilterSet, cls).get_filters() new_filters = {} for existing_filter_name, existing_filter in filters.items(): # Loop over existing filters to extract metadata by which to create new filters - # It the filter makes use of a custom filter method or lookup expression skip it + # If the filter makes use of a custom filter method or lookup expression skip it # as we cannot sanely handle these cases in a generic mannor if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'in']: continue @@ -232,6 +234,9 @@ class BaseFilterSet(django_filters.FilterSet): try: if existing_filter_name in cls.declared_filters: + # The filter field has been explicity defined on the filterset class so we must manually + # create the new filter with the same type because there is no guarantee the defined type + # is the same as the default type for the field resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid new_filter = type(existing_filter)( field_name=field_name, @@ -242,13 +247,14 @@ class BaseFilterSet(django_filters.FilterSet): **existing_filter.extra ) else: + # The filter field is listed in Meta.fields so we can safely rely on default behaviour new_filter = cls.filter_for_field(field, field_name, lookup_expr) except django_filters.exceptions.FieldLookupError: # The filter could not be created because the lookup expression is not supported on the field continue if lookup_name.startswith('n'): - # This is a negation filter which requires a queryselt.exclud() clause + # This is a negation filter which requires a queryset.exclude() clause new_filter.exclude = True new_filters[new_filter_name] = new_filter From a136a0788c649d5947202bf0780f004bfb5a5232 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Tue, 18 Feb 2020 00:32:58 -0500 Subject: [PATCH 04/61] #4121 - dynamic filter lookup expressions --- netbox/utilities/constants.py | 4 ++ netbox/utilities/filters.py | 33 ++++++------ netbox/utilities/tests/test_filters.py | 74 +++++++++++++++++++++++++- 3 files changed, 94 insertions(+), 17 deletions(-) diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py index d7f819e8c..1f8e13553 100644 --- a/netbox/utilities/constants.py +++ b/netbox/utilities/constants.py @@ -53,6 +53,10 @@ FILTER_NUMERIC_BASED_LOOKUP_MAP = dict( gt='gt' ) +FILTER_NEGATION_LOOKUP_MAP = dict( + n='exact' +) + FILTER_LOOKUP_HELP_TEXT_MAP = dict( icontains='case insensitive contains', iendswith='case insensitive ends with', diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index d1364cf4b..5e9e1f4c1 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -8,7 +8,8 @@ from django_filters.utils import get_model_field, resolve_field from extras.models import Tag from utilities.constants import ( - FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_LOOKUP_HELP_TEXT_MAP, FILTER_NUMERIC_BASED_LOOKUP_MAP + FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_LOOKUP_HELP_TEXT_MAP, FILTER_NEGATION_LOOKUP_MAP, + FILTER_NUMERIC_BASED_LOOKUP_MAP ) @@ -193,15 +194,6 @@ class BaseFilterSet(django_filters.FilterSet): # Choose the lookup expression map based on the filter type if isinstance(existing_filter, ( - django_filters.filters.CharFilter, - django_filters.MultipleChoiceFilter, - MultiValueCharFilter, - MultiValueMACAddressFilter, - TagFilter - )): - lookup_map = FILTER_CHAR_BASED_LOOKUP_MAP - - elif isinstance(existing_filter, ( MultiValueDateFilter, MultiValueDateTimeFilter, MultiValueNumberFilter, @@ -212,13 +204,19 @@ class BaseFilterSet(django_filters.FilterSet): elif isinstance(existing_filter, ( django_filters.ModelChoiceFilter, django_filters.ModelMultipleChoiceFilter, - NumericInFilter, TreeNodeMultipleChoiceFilter, - )): + TagFilter + )) or existing_filter.extra.get('choices'): # These filter types support only negation - lookup_map = dict( - n='exact' - ) + lookup_map = FILTER_NEGATION_LOOKUP_MAP + + elif isinstance(existing_filter, ( + django_filters.filters.CharFilter, + django_filters.MultipleChoiceFilter, + MultiValueCharFilter, + MultiValueMACAddressFilter + )): + lookup_map = FILTER_CHAR_BASED_LOOKUP_MAP else: # Do not augment any other filter types with more lookup expressions @@ -231,6 +229,8 @@ class BaseFilterSet(django_filters.FilterSet): # Create new filters for each lookup expression in the map for lookup_name, lookup_expr in lookup_map.items(): new_filter_name = '{}__{}'.format(existing_filter_name, lookup_name) + if existing_filter.lookup_expr == 'in': + lookup_expr = 'in' # 'in' lookups must remain to avoid unwanted slicing on certain querysets try: if existing_filter_name in cls.declared_filters: @@ -255,7 +255,8 @@ class BaseFilterSet(django_filters.FilterSet): if lookup_name.startswith('n'): # This is a negation filter which requires a queryset.exclude() clause - new_filter.exclude = True + # Of course setting the negation of the existing filter's exclude attribute handles both cases + new_filter.exclude = not existing_filter.exclude new_filters[new_filter_name] = new_filter diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py index 513e11bca..a1cb771a1 100644 --- a/netbox/utilities/tests/test_filters.py +++ b/netbox/utilities/tests/test_filters.py @@ -2,8 +2,9 @@ from django.conf import settings from django.test import TestCase import django_filters +from dcim.filters import SiteFilterSet from dcim.models import Region, Site -from utilities.filters import TreeNodeMultipleChoiceFilter +from utilities.filters import BaseFilterSet, TreeNodeMultipleChoiceFilter class TreeNodeMultipleChoiceFilterTest(TestCase): @@ -60,3 +61,74 @@ class TreeNodeMultipleChoiceFilterTest(TestCase): self.assertEqual(qs.count(), 2) self.assertEqual(qs[0], self.site1) self.assertEqual(qs[1], self.site3) + + +class DynamicFilterLookupExpressionTest(TestCase): + """ + These tests ensure of the utilities.filters.BaseFilterSet.get_filters() method + correctly generates dynamic filter expressions + """ + + def setUp(self): + + super().setUp() + + self.region1 = Region.objects.create(name='Test Region 1', slug='test-region-1') + self.region2 = Region.objects.create(name='Test Region 2', slug='test-region-2') + self.site1 = Site.objects.create(region=self.region1, name='Test Site 1', slug='ABC-test-site1-ABC', asn=65001) + self.site2 = Site.objects.create(region=self.region2, name='Test Site 2', slug='def-test-site2-def', asn=65101) + self.site3 = Site.objects.create(region=None, name='Test Site 3', slug='ghi-test-site3-ghi', asn=65201) + + self.queryset = Site.objects.all() + + def test_site_name_negation(self): + params = {'name__n': ['Test Site 1']} + self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + + def test_site_slug_icontains(self): + params = {'slug__ic': ['abc']} + self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 1) + + def test_site_slug_icontains_negation(self): + params = {'slug__nic': ['abc']} + self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + + def test_site_slug_startswith(self): + params = {'slug__isw': ['abc']} + self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 1) + + def test_site_slug_startswith_negation(self): + params = {'slug__nisw': ['abc']} + self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + + def test_site_slug_endswith(self): + params = {'slug__iew': ['abc']} + self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 1) + + def test_site_slug_endswith_negation(self): + params = {'slug__niew': ['abc']} + self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + + def test_site_asn_lt(self): + params = {'asn__lt': [65101]} + self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 1) + + def test_site_asn_lte(self): + params = {'asn__lte': [65101]} + self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + + def test_site_asn_gt(self): + params = {'asn__lt': [65101]} + self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 1) + + def test_site_asn_gte(self): + params = {'asn__gte': [65101]} + self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + + def test_site_region_negation(self): + params = {'region__n': ['test-region-1']} + self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + + def test_site_region_id_negation(self): + params = {'region_id__n': [self.region1.pk]} + self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) From 67565ca191a1976b6a4938b2bbfde578997b6264 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Mon, 24 Feb 2020 15:03:07 -0500 Subject: [PATCH 05/61] added docs and more tests --- docs/api/filtering.md | 71 ++++++++++ docs/api/overview.md | 2 + mkdocs.yml | 1 + netbox/utilities/constants.py | 13 -- netbox/utilities/filters.py | 3 +- netbox/utilities/tests/test_filters.py | 186 +++++++++++++++++++++---- 6 files changed, 231 insertions(+), 45 deletions(-) create mode 100644 docs/api/filtering.md diff --git a/docs/api/filtering.md b/docs/api/filtering.md new file mode 100644 index 000000000..e7b51d303 --- /dev/null +++ b/docs/api/filtering.md @@ -0,0 +1,71 @@ +# API Filtering + +The NetBox API supports robust filtering of results based on the fields of each model. +Generally speaking you are able to filter based on the attributes (fields) present in +the response body. Please note however that certain read-only or metadata fields are not +filterable. + +Filtering is achieved by passing HTTP query parameters and the parameter name is the +name of the field you wish to filter on and the value is the field value. + +E.g. filtering based on a device's name: +``` +/api/dcim/devices/?name=DC-SPINE-1 +``` + +## Multi Value Logic + +While you are able to filter based on an arbitrary number of fields, you are also able to +pass multiple values for the same field. In most cases filtering on multiple values is +implemented as a logical OR operation. A notible exception is the `tag` filter which +is a logical AND. Passing multiple values for one field, can be combined with other fields. + +For example, filtering for devices with either the name of DC-SPINE-1 _or_ DC-LEAF-4: +``` +/api/dcim/devices/?name=DC-SPINE-1&name=DC-LEAF-4 +``` + +Filtering for devices with tag `router` and `customer-a` will return only devices with +_both_ of those tags applied: +``` +/api/dcim/devices/?tag=router&tag=customer-a +``` + +## Lookup Expressions + +Certain model fields also support filtering using additonal lookup expressions. This allows +for negation and other context specific filtering. + +These lookup expressions can be applied by adding a suffix to the desired field's name. +E.g. `mac_address__n`. In this case, the filter expression is for negation and it is seperated +by two underscores. Below are the lookup expressions that are supported across different field +types. + +### Numeric Fields + +Numeric based fields (ASN, VLAN ID, etc) support these lookup expressions: + +- `n` - not equal (negation) +- `lt` - less than +- `lte` - less than or equal +- `gt` - greater than +- `gte` - greater than or equal + +### String Fields + +String based (char) fields (Name, Address, etc) support these lookup expressions: + +- `n` - not equal (negation) +- `ic` - case insensitive contains +- `nic` - negated case insensitive contains +- `isw` - case insensitive starts with +- `nisw` - negated case insensitive starts with +- `iew` - case insensitive ends with +- `niew` - negated case insensitive ends with +- `ie` - case sensitive exact match +- `nie` - negated case sensitive exact match + +### Foreign Keys & Other Fields + +Certain other fields, namely foreign key relationships support just the negation +expression: `n`. diff --git a/docs/api/overview.md b/docs/api/overview.md index 3841e8bbf..daa4f7c63 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -62,6 +62,8 @@ Lists of objects can be filtered using a set of query parameters. For example, t GET /api/dcim/interfaces/?device_id=123 ``` +See [filtering](filtering.md) for more details. + # Serialization The NetBox API employs three types of serializers to represent model data: diff --git a/mkdocs.yml b/mkdocs.yml index 4ba91dfe5..9dc8b8578 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -50,6 +50,7 @@ pages: - Authentication: 'api/authentication.md' - Working with Secrets: 'api/working-with-secrets.md' - Examples: 'api/examples.md' + - Filtering: 'api/filtering.md' - Development: - Introduction: 'development/index.md' - Style Guide: 'development/style-guide.md' diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py index 1665b45c2..bf2cba592 100644 --- a/netbox/utilities/constants.py +++ b/netbox/utilities/constants.py @@ -57,19 +57,6 @@ FILTER_NEGATION_LOOKUP_MAP = dict( n='exact' ) -FILTER_LOOKUP_HELP_TEXT_MAP = dict( - icontains='case insensitive contains', - iendswith='case insensitive ends with', - istartswith='case insensitive starts with', - iexact='case insensitive exact', - exact='case sensitive exact', - lt='less than', - lte='less than or equal', - gt='greater than', - gte='greater than or equal', - n='negated' -) - # Keys for PostgreSQL advisory locks. These are arbitrary bigints used by # the advisory_lock contextmanager. When a lock is acquired, diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 5e9e1f4c1..2a9f04316 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -8,8 +8,7 @@ from django_filters.utils import get_model_field, resolve_field from extras.models import Tag from utilities.constants import ( - FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_LOOKUP_HELP_TEXT_MAP, FILTER_NEGATION_LOOKUP_MAP, - FILTER_NUMERIC_BASED_LOOKUP_MAP + FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_NUMERIC_BASED_LOOKUP_MAP ) diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py index a1cb771a1..ca1c10faf 100644 --- a/netbox/utilities/tests/test_filters.py +++ b/netbox/utilities/tests/test_filters.py @@ -2,8 +2,12 @@ from django.conf import settings from django.test import TestCase import django_filters -from dcim.filters import SiteFilterSet -from dcim.models import Region, Site +from dcim.filters import DeviceFilterSet, SiteFilterSet +from dcim.choices import * +from dcim.models import ( + Device, DeviceRole, DeviceType, Interface, Manufacturer, Platform, Rack, Region, Site +) +from ipam.models import IPAddress from utilities.filters import BaseFilterSet, TreeNodeMultipleChoiceFilter @@ -68,67 +72,189 @@ class DynamicFilterLookupExpressionTest(TestCase): These tests ensure of the utilities.filters.BaseFilterSet.get_filters() method correctly generates dynamic filter expressions """ + device_queryset = Device.objects.all() + device_filterset = DeviceFilterSet + site_queryset = Site.objects.all() + site_filterset = SiteFilterSet - def setUp(self): + @classmethod + def setUpTestData(cls): - super().setUp() + manufacturers = ( + Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), + Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), + Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), + ) + Manufacturer.objects.bulk_create(manufacturers) - self.region1 = Region.objects.create(name='Test Region 1', slug='test-region-1') - self.region2 = Region.objects.create(name='Test Region 2', slug='test-region-2') - self.site1 = Site.objects.create(region=self.region1, name='Test Site 1', slug='ABC-test-site1-ABC', asn=65001) - self.site2 = Site.objects.create(region=self.region2, name='Test Site 2', slug='def-test-site2-def', asn=65101) - self.site3 = Site.objects.create(region=None, name='Test Site 3', slug='ghi-test-site3-ghi', asn=65201) + device_types = ( + DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', is_full_depth=True), + DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', is_full_depth=True), + DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', is_full_depth=False), + ) + DeviceType.objects.bulk_create(device_types) - self.queryset = Site.objects.all() + device_roles = ( + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + DeviceRole(name='Device Role 3', slug='device-role-3'), + ) + DeviceRole.objects.bulk_create(device_roles) + + platforms = ( + Platform(name='Platform 1', slug='platform-1'), + Platform(name='Platform 2', slug='platform-2'), + Platform(name='Platform 3', slug='platform-3'), + ) + Platform.objects.bulk_create(platforms) + + regions = ( + Region(name='Region 1', slug='region-1'), + Region(name='Region 2', slug='region-2'), + Region(name='Region 3', slug='region-3'), + ) + for region in regions: + region.save() + + sites = ( + Site(name='Site 1', slug='abc-site-1', region=regions[0], asn=65001), + Site(name='Site 2', slug='def-site-2', region=regions[1], asn=65101), + Site(name='Site 3', slug='ghi-site-3', region=regions[2], asn=65201), + ) + Site.objects.bulk_create(sites) + + racks = ( + Rack(name='Rack 1', site=sites[0]), + Rack(name='Rack 2', site=sites[1]), + Rack(name='Rack 3', site=sites[2]), + ) + Rack.objects.bulk_create(racks) + + devices = ( + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], serial='ABC', asset_tag='1001', site=sites[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_ACTIVE, local_context_data={"foo": 123}), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], serial='DEF', asset_tag='1002', site=sites[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_STAGED), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], serial='GHI', asset_tag='1003', site=sites[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, status=DeviceStatusChoices.STATUS_FAILED), + ) + Device.objects.bulk_create(devices) + + interfaces = ( + Interface(device=devices[0], name='Interface 1', mac_address='00-00-00-00-00-01'), + Interface(device=devices[0], name='Interface 2', mac_address='aa-00-00-00-00-01'), + Interface(device=devices[1], name='Interface 3', mac_address='00-00-00-00-00-02'), + Interface(device=devices[1], name='Interface 4', mac_address='bb-00-00-00-00-02'), + Interface(device=devices[2], name='Interface 5', mac_address='00-00-00-00-00-03'), + Interface(device=devices[2], name='Interface 6', mac_address='cc-00-00-00-00-03'), + ) + Interface.objects.bulk_create(interfaces) def test_site_name_negation(self): - params = {'name__n': ['Test Site 1']} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + params = {'name__n': ['Site 1']} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) def test_site_slug_icontains(self): - params = {'slug__ic': ['abc']} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 1) + params = {'slug__ic': ['-1']} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) def test_site_slug_icontains_negation(self): - params = {'slug__nic': ['abc']} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + params = {'slug__nic': ['-1']} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) def test_site_slug_startswith(self): params = {'slug__isw': ['abc']} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 1) + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) def test_site_slug_startswith_negation(self): params = {'slug__nisw': ['abc']} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) def test_site_slug_endswith(self): - params = {'slug__iew': ['abc']} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 1) + params = {'slug__iew': ['-1']} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) def test_site_slug_endswith_negation(self): - params = {'slug__niew': ['abc']} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + params = {'slug__niew': ['-1']} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) def test_site_asn_lt(self): params = {'asn__lt': [65101]} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 1) + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) def test_site_asn_lte(self): params = {'asn__lte': [65101]} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) def test_site_asn_gt(self): params = {'asn__lt': [65101]} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 1) + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) def test_site_asn_gte(self): params = {'asn__gte': [65101]} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) def test_site_region_negation(self): - params = {'region__n': ['test-region-1']} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + params = {'region__n': ['region-1']} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) def test_site_region_id_negation(self): - params = {'region_id__n': [self.region1.pk]} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + params = {'region_id__n': [Region.objects.first().pk]} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) + + def test_device_name_eq(self): + params = {'name': ['Device 1']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) + + def test_device_name_negation(self): + params = {'name__n': ['Device 1']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + + def test_device_name_startswith(self): + params = {'name__isw': ['Device']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 3) + + def test_device_name_startswith_negation(self): + params = {'name__nisw': ['Device 1']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + + def test_device_name_endswith(self): + params = {'name__iew': [' 1']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) + + def test_device_name_endswith_negation(self): + params = {'name__niew': [' 1']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + + def test_device_name_icontains(self): + params = {'name__ic': [' 2']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) + + def test_device_name_icontains_negation(self): + params = {'name__nic': [' ']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 0) + + def test_device_mac_address_negation(self): + params = {'mac_address__n': ['00-00-00-00-00-01', 'aa-00-00-00-00-01']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + + def test_device_mac_address_startswith(self): + params = {'mac_address__isw': ['aa:']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) + + def test_device_mac_address_startswith_negation(self): + params = {'mac_address__nisw': ['aa:']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + + def test_device_mac_address_endswith(self): + params = {'mac_address__iew': [':02']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) + + def test_device_mac_address_endswith_negation(self): + params = {'mac_address__niew': [':02']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + + def test_device_mac_address_icontains(self): + params = {'mac_address__ic': ['aa:', 'bb']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + + def test_device_mac_address_icontains_negation(self): + params = {'mac_address__nic': ['aa:', 'bb']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) From afc8c9bfe9fb4dadb5220804a87733ef2be0c447 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Tue, 25 Feb 2020 13:50:31 -0500 Subject: [PATCH 06/61] fix tenancy filterset bases --- netbox/dcim/filters.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 0d7c440c2..7b98359c8 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -77,7 +77,7 @@ class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet): fields = ['id', 'name', 'slug'] -class SiteFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -170,7 +170,7 @@ class RackRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet): fields = ['id', 'name', 'slug', 'color'] -class RackFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -497,7 +497,13 @@ class PlatformFilterSet(BaseFilterSet, NameSlugSearchFilterSet): fields = ['id', 'name', 'slug', 'napalm_driver'] -class DeviceFilterSet(BaseFilterSet, LocalConfigContextFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class DeviceFilterSet( + BaseFilterSet, + TenancyFilterSet, + LocalConfigContextFilterSet, + CustomFieldFilterSet, + CreatedUpdatedFilterSet +): id__in = NumericInFilter( field_name='id', lookup_expr='in' From 5befe66aa54ee2fc9e67f4003b8b4d842fd2ac20 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 25 Feb 2020 15:11:04 -0500 Subject: [PATCH 07/61] Post-release version bump --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 3bc673796..89958bc13 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured # Environment setup # -VERSION = '2.7.8' +VERSION = '2.7.9-dev' # Hostname HOSTNAME = platform.node() From 3b4607d30dd831dc54d87426eeaacd59baf8283b Mon Sep 17 00:00:00 2001 From: John Anderson Date: Tue, 25 Feb 2020 15:16:27 -0500 Subject: [PATCH 08/61] refactor lookup map logic --- netbox/utilities/filters.py | 64 +++++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 27 deletions(-) diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 2a9f04316..d88366d24 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -170,6 +170,39 @@ class BaseFilterSet(django_filters.FilterSet): }, }) + @staticmethod + def _get_filter_lookup_dict(existing_filter): + # Choose the lookup expression map based on the filter type + if isinstance(existing_filter, ( + MultiValueDateFilter, + MultiValueDateTimeFilter, + MultiValueNumberFilter, + MultiValueTimeFilter + )): + lookup_map = FILTER_NUMERIC_BASED_LOOKUP_MAP + + elif isinstance(existing_filter, ( + django_filters.ModelChoiceFilter, + django_filters.ModelMultipleChoiceFilter, + TreeNodeMultipleChoiceFilter, + TagFilter + )) or existing_filter.extra.get('choices'): + # These filter types support only negation + lookup_map = FILTER_NEGATION_LOOKUP_MAP + + elif isinstance(existing_filter, ( + django_filters.filters.CharFilter, + django_filters.MultipleChoiceFilter, + MultiValueCharFilter, + MultiValueMACAddressFilter + )): + lookup_map = FILTER_CHAR_BASED_LOOKUP_MAP + + else: + lookup_map = None + + return lookup_map + @classmethod def get_filters(cls): """ @@ -192,33 +225,9 @@ class BaseFilterSet(django_filters.FilterSet): continue # Choose the lookup expression map based on the filter type - if isinstance(existing_filter, ( - MultiValueDateFilter, - MultiValueDateTimeFilter, - MultiValueNumberFilter, - MultiValueTimeFilter - )): - lookup_map = FILTER_NUMERIC_BASED_LOOKUP_MAP - - elif isinstance(existing_filter, ( - django_filters.ModelChoiceFilter, - django_filters.ModelMultipleChoiceFilter, - TreeNodeMultipleChoiceFilter, - TagFilter - )) or existing_filter.extra.get('choices'): - # These filter types support only negation - lookup_map = FILTER_NEGATION_LOOKUP_MAP - - elif isinstance(existing_filter, ( - django_filters.filters.CharFilter, - django_filters.MultipleChoiceFilter, - MultiValueCharFilter, - MultiValueMACAddressFilter - )): - lookup_map = FILTER_CHAR_BASED_LOOKUP_MAP - - else: - # Do not augment any other filter types with more lookup expressions + lookup_map = cls._get_filter_lookup_dict(existing_filter) + if lookup_map is None: + # Do not augment this filter type with more lookup expressions continue # Get properties of the existing filter for later use @@ -247,6 +256,7 @@ class BaseFilterSet(django_filters.FilterSet): ) else: # The filter field is listed in Meta.fields so we can safely rely on default behaviour + # Will raise FieldLookupError if the lookup is invalid new_filter = cls.filter_for_field(field, field_name, lookup_expr) except django_filters.exceptions.FieldLookupError: # The filter could not be created because the lookup expression is not supported on the field From 5a61bbec269c20af7f2ffe8293c20754d7c3db56 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 26 Feb 2020 09:11:38 -0500 Subject: [PATCH 09/61] Fixes #4277: Fix filtering of clusters by tenant --- docs/release-notes/version-2.7.md | 8 ++++++++ netbox/virtualization/filters.py | 6 +----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index f9557bf34..62b85bc83 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -1,3 +1,11 @@ +# v2.7.9 (FUTURE) + +## Bug Fixes + +* [#4277](https://github.com/netbox-community/netbox/issues/4277) - Fix filtering of clusters by tenant + +--- + # v2.7.8 (2020-02-25) ## Enhancements diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index 0d817ef47..79313f36e 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -34,7 +34,7 @@ class ClusterGroupFilterSet(NameSlugSearchFilterSet): fields = ['id', 'name', 'slug'] -class ClusterFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): +class ClusterFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -84,10 +84,6 @@ class ClusterFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): to_field_name='slug', label='Cluster type (slug)', ) - tenant = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label="Tenant (ID)" - ) tag = TagFilter() class Meta: From c78df40cb005a13595446a93fe80785c9dd404e3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 26 Feb 2020 11:40:31 -0500 Subject: [PATCH 10/61] Refactor installtion docs --- docs/additional-features/napalm.md | 2 +- docs/installation/1-postgresql.md | 19 ++++---- docs/installation/2-redis.md | 22 +++++++++ .../installation/{2-netbox.md => 3-netbox.md} | 46 ++++++++++--------- .../{3-http-daemon.md => 4-http-daemon.md} | 10 ++-- docs/installation/{4-ldap.md => 5-ldap.md} | 16 +++---- docs/installation/index.md | 7 +-- mkdocs.yml | 21 +++++---- 8 files changed, 86 insertions(+), 57 deletions(-) create mode 100644 docs/installation/2-redis.md rename docs/installation/{2-netbox.md => 3-netbox.md} (94%) rename docs/installation/{3-http-daemon.md => 4-http-daemon.md} (98%) rename docs/installation/{4-ldap.md => 5-ldap.md} (96%) diff --git a/docs/additional-features/napalm.md b/docs/additional-features/napalm.md index c8e8b8b3a..304d892c4 100644 --- a/docs/additional-features/napalm.md +++ b/docs/additional-features/napalm.md @@ -3,7 +3,7 @@ NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API. !!! info - To enable the integration, the NAPALM library must be installed. See [installation steps](../../installation/2-netbox/#napalm-automation-optional) for more information. + To enable the integration, the NAPALM library must be installed. See [installation steps](../../installation/3-netbox/#napalm-automation-optional) for more information. ``` GET /api/dcim/devices/1/napalm/?method=get_environment diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md index 376a62ae2..f6f217994 100644 --- a/docs/installation/1-postgresql.md +++ b/docs/installation/1-postgresql.md @@ -1,14 +1,13 @@ -NetBox requires a PostgreSQL database to store data. This can be hosted locally or on a remote server. (Please note that MySQL is not supported, as NetBox leverages PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/current/static/datatype-net-types.html).) - -!!! note - The installation instructions provided here have been tested to work on Ubuntu 18.04 and CentOS 7.5. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors. +This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md). !!! warning - NetBox requires PostgreSQL 9.4 or higher. + NetBox requires PostgreSQL 9.4 or higher. Please note that MySQL and other relational databases are **not** supported. -# Installation +The installation instructions provided here have been tested to work on Ubuntu 18.04 and CentOS 7.5. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors. -**Ubuntu** +## Installation + +#### Ubuntu If a recent enough version of PostgreSQL is not available through your distribution's package manager, you'll need to install it from an official [PostgreSQL repository](https://wiki.postgresql.org/wiki/Apt). @@ -17,7 +16,7 @@ If a recent enough version of PostgreSQL is not available through your distribut # apt-get install -y postgresql libpq-dev ``` -**CentOS** +#### CentOS CentOS 7.5 does not ship with a recent enough version of PostgreSQL, so it will need to be installed from an external repository. The instructions below show the installation of PostgreSQL 9.6. @@ -41,7 +40,7 @@ Then, start the service and enable it to run at boot: # systemctl enable postgresql-9.6 ``` -# Database Creation +## Database Creation At a minimum, we need to create a database for NetBox and assign it a username and password for authentication. This is done with the following commands. @@ -62,6 +61,8 @@ GRANT postgres=# \q ``` +## Verify Service Status + You can verify that authentication works issuing the following command and providing the configured password. (Replace `localhost` with your database server if using a remote database.) ```no-highlight diff --git a/docs/installation/2-redis.md b/docs/installation/2-redis.md new file mode 100644 index 000000000..0ef7de78a --- /dev/null +++ b/docs/installation/2-redis.md @@ -0,0 +1,22 @@ +[Redis](https://redis.io/) is an in-memory key-value store which NetBox employs for caching and queuing. This section entails the installation and configuration of a local Redis instance. If you already have a Redis service in place, skip to [the next section](3-netbox.md). + +#### Ubuntu + +```no-highlight +# apt-get install -y redis-server +``` + +#### CentOS + +```no-highlight +# yum install -y redis +``` + +## Verify Service Status + +Use the `redis-cli` utility to ensure the Redis service is functional: + +```no-highlight +$ redis-cli ping +PONG +``` diff --git a/docs/installation/2-netbox.md b/docs/installation/3-netbox.md similarity index 94% rename from docs/installation/2-netbox.md rename to docs/installation/3-netbox.md index cbe2c70c0..5c29b3996 100644 --- a/docs/installation/2-netbox.md +++ b/docs/installation/3-netbox.md @@ -1,25 +1,27 @@ -# Installation - This section of the documentation discusses installing and configuring the NetBox application. Begin by installing all system packages required by NetBox and its dependencies: -**Ubuntu** +## Install System Packages + +#### Ubuntu ```no-highlight -# apt-get install -y python3 python3-pip python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev redis-server zlib1g-dev +# apt-get install -y python3 python3-pip python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev ``` -**CentOS** +#### CentOS ```no-highlight # yum install -y epel-release -# yum install -y gcc python36 python36-devel python36-setuptools libxml2-devel libxslt-devel libffi-devel openssl-devel redhat-rpm-config redis +# yum install -y gcc python36 python36-devel python36-setuptools libxml2-devel libxslt-devel libffi-devel openssl-devel redhat-rpm-config # easy_install-3.6 pip # ln -s /usr/bin/python3.6 /usr/bin/python3 ``` +## Download NetBox + You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub. -## Option A: Download a Release +### Option A: Download a Release Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive and extract it to your desired path. In this example, we'll use `/opt/netbox`. @@ -31,7 +33,7 @@ Download the [latest stable release](https://github.com/netbox-community/netbox/ # cd /opt/netbox/ ``` -## Option B: Clone the Git Repository +### Option B: Clone the Git Repository Create the base directory for the NetBox installation. For this guide, we'll use `/opt/netbox`. @@ -41,13 +43,13 @@ Create the base directory for the NetBox installation. For this guide, we'll use If `git` is not already installed, install it: -**Ubuntu** +#### Ubuntu ```no-highlight # apt-get install -y git ``` -**CentOS** +#### CentOS ```no-highlight # yum install -y git @@ -71,7 +73,7 @@ Checking connectivity... done. `# chown -R netbox:netbox /opt/netbox/netbox/media/` -# Install Python Packages +## Install Python Packages Install the required Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the system dependencies listed above.) @@ -82,7 +84,7 @@ Install the required Python packages using pip. (If you encounter any compilatio !!! note If you encounter errors while installing the required packages, check that you're running a recent version of pip (v9.0.1 or higher) with the command `pip3 -V`. -## NAPALM Automation (Optional) +### NAPALM Automation (Optional) NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API. Installation of NAPALM is optional. To enable it, install the `napalm` package using pip or pip3: @@ -90,7 +92,7 @@ NetBox supports integration with the [NAPALM automation](https://napalm-automati # pip3 install napalm ``` -## Remote File Storage (Optional) +### Remote File Storage (Optional) By default, NetBox will use the local filesystem to storage uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired backend](../../configuration/optional-settings/#storage_backend) in `configuration.py`. @@ -98,7 +100,7 @@ By default, NetBox will use the local filesystem to storage uploaded files. To u # pip3 install django-storages ``` -# Configuration +## Configuration Move into the NetBox configuration directory and make a copy of `configuration.example.py` named `configuration.py`. @@ -114,7 +116,7 @@ Open `configuration.py` with your preferred editor and set the following variabl * `REDIS` * `SECRET_KEY` -## ALLOWED_HOSTS +### ALLOWED_HOSTS This is a list of the valid hostnames by which this server can be reached. You must specify at least one name or IP address. @@ -124,7 +126,7 @@ Example: ALLOWED_HOSTS = ['netbox.example.com', '192.0.2.123'] ``` -## DATABASE +### DATABASE This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, replace `localhost` with its address. See the [configuration documentation](../../configuration/required-settings/#database) for more detail on individual parameters. @@ -141,7 +143,7 @@ DATABASE = { } ``` -## REDIS +### REDIS Redis is a in-memory key-value store required as part of the NetBox installation. It is used for features such as webhooks and caching. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](../../configuration/required-settings/#redis) for more detail on individual parameters. @@ -166,7 +168,7 @@ REDIS = { } ``` -## SECRET_KEY +### SECRET_KEY Generate a random secret key of at least 50 alphanumeric characters. This key must be unique to this installation and must not be shared outside the local system. @@ -175,7 +177,7 @@ You may use the script located at `netbox/generate_secret_key.py` to generate a !!! note In the case of a highly available installation with multiple web servers, `SECRET_KEY` must be identical among all servers in order to maintain a persistent user session state. -# Run Database Migrations +## Run Database Migrations Before NetBox can run, we need to install the database schema. This is done by running `python3 manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example): @@ -194,7 +196,7 @@ Running migrations: If this step results in a PostgreSQL authentication error, ensure that the username and password created in the database match what has been specified in `configuration.py` -# Create a Super User +## Create a Super User NetBox does not come with any predefined user accounts. You'll need to create a super user to be able to log into NetBox: @@ -207,7 +209,7 @@ Password (again): Superuser created successfully. ``` -# Collect Static Files +## Collect Static Files ```no-highlight # python3 manage.py collectstatic --no-input @@ -215,7 +217,7 @@ Superuser created successfully. 959 static files copied to '/opt/netbox/netbox/static'. ``` -# Test the Application +## Test the Application At this point, NetBox should be able to run. We can verify this by starting a development instance: diff --git a/docs/installation/3-http-daemon.md b/docs/installation/4-http-daemon.md similarity index 98% rename from docs/installation/3-http-daemon.md rename to docs/installation/4-http-daemon.md index 4225f8413..c50d71e4c 100644 --- a/docs/installation/3-http-daemon.md +++ b/docs/installation/4-http-daemon.md @@ -3,9 +3,9 @@ We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for !!! info For the sake of brevity, only Ubuntu 18.04 instructions are provided here, but this sort of web server and WSGI configuration is not unique to NetBox. Please consult your distribution's documentation for assistance if needed. -# Web Server Installation +## HTTP Daemon Installation -## Option A: nginx +### Option A: nginx The following will serve as a minimal nginx configuration. Be sure to modify your server name and installation path appropriately. @@ -52,7 +52,7 @@ Restart the nginx service to use the new configuration. To enable SSL, consider this guide on [securing nginx with Let's Encrypt](https://www.digitalocean.com/community/tutorials/how-to-secure-nginx-with-let-s-encrypt-on-ubuntu-16-04). -## Option B: Apache +### Option B: Apache ```no-highlight # apt-get install -y apache2 libapache2-mod-wsgi-py3 @@ -102,7 +102,7 @@ To enable SSL, consider this guide on [securing Apache with Let's Encrypt](https !!! note Certain components of NetBox (such as the display of rack elevation diagrams) rely on the use of embedded objects. Ensure that your HTTP server configuration does not override the `X-Frame-Options` response header set by NetBox. -# gunicorn Installation +## gunicorn Installation Install gunicorn: @@ -119,7 +119,7 @@ Copy `/opt/netbox/contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. We make a c You may wish to edit this file to change the bound IP address or port number, or to make performance-related adjustments. -# systemd configuration +## systemd configuration We'll use systemd to control the daemonization of NetBox services. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory: diff --git a/docs/installation/4-ldap.md b/docs/installation/5-ldap.md similarity index 96% rename from docs/installation/4-ldap.md rename to docs/installation/5-ldap.md index 953d3cb28..0a5a4f76a 100644 --- a/docs/installation/4-ldap.md +++ b/docs/installation/5-ldap.md @@ -1,8 +1,8 @@ This guide explains how to implement LDAP authentication using an external server. User authentication will fall back to built-in Django users in the event of a failure. -# Requirements +## Install Requirements -## Install openldap-devel +#### Install openldap-devel On Ubuntu: @@ -16,17 +16,17 @@ On CentOS: sudo yum install -y openldap-devel ``` -## Install django-auth-ldap +#### Install django-auth-ldap ```no-highlight pip3 install django-auth-ldap ``` -# Configuration +## Configuration Create a file in the same directory as `configuration.py` (typically `netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](http://django-auth-ldap.readthedocs.io/). -## General Server Configuration +### General Server Configuration !!! info When using Windows Server 2012 you may need to specify a port on `AUTH_LDAP_SERVER_URI`. Use `3269` for secure, or `3268` for non-secure. @@ -54,7 +54,7 @@ LDAP_IGNORE_CERT_ERRORS = True STARTTLS can be configured by setting `AUTH_LDAP_START_TLS = True` and using the `ldap://` URI scheme. -## User Authentication +### User Authentication !!! info When using Windows Server 2012, `AUTH_LDAP_USER_DN_TEMPLATE` should be set to None. @@ -79,7 +79,7 @@ AUTH_LDAP_USER_ATTR_MAP = { } ``` -# User Groups for Permissions +## User Groups for Permissions !!! info When using Microsoft Active Directory, support for nested groups can be activated by using `NestedGroupOfNamesType()` instead of `GroupOfNamesType()` for `AUTH_LDAP_GROUP_TYPE`. You will also need to modify the import line to use `NestedGroupOfNamesType` instead of `GroupOfNamesType` . @@ -121,7 +121,7 @@ AUTH_LDAP_CACHE_TIMEOUT = 3600 !!! warning Authentication will fail if the groups (the distinguished names) do not exist in the LDAP directory. -# Troubleshooting LDAP +## Troubleshooting LDAP `supervisorctl restart netbox` restarts the Netbox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/supervisor/`. diff --git a/docs/installation/index.md b/docs/installation/index.md index 59631bf7a..8df387ca8 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -3,9 +3,10 @@ The following sections detail how to set up a new instance of NetBox: 1. [PostgreSQL database](1-postgresql.md) -2. [NetBox components](2-netbox.md) -3. [HTTP daemon](3-http-daemon.md) -4. [LDAP authentication](4-ldap.md) (optional) +1. [Redis](2-redis.md) +3. [NetBox components](3-netbox.md) +4. [HTTP daemon](4-http-daemon.md) +5. [LDAP authentication](5-ldap.md) (optional) # Upgrading diff --git a/mkdocs.yml b/mkdocs.yml index 4ba91dfe5..6de4b532f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,15 +1,21 @@ -site_name: NetBox -theme: readthedocs +site_name: NetBox Documentation +site_url: https://netbox.readthedocs.io/ repo_url: https://github.com/netbox-community/netbox +theme: + name: readthedocs + navigation_depth: 3 +markdown_extensions: + - admonition: -pages: +nav: - Introduction: 'index.md' - Installation: - Installing NetBox: 'installation/index.md' - 1. PostgreSQL: 'installation/1-postgresql.md' - - 2. NetBox: 'installation/2-netbox.md' - - 3. HTTP Daemon: 'installation/3-http-daemon.md' - - 4. LDAP (Optional): 'installation/4-ldap.md' + - 2. Redis: 'installation/2-redis.md' + - 3. NetBox: 'installation/3-netbox.md' + - 4. HTTP Daemon: 'installation/4-http-daemon.md' + - 5. LDAP (Optional): 'installation/5-ldap.md' - Upgrading NetBox: 'installation/upgrading.md' - Migrating to Python3: 'installation/migrating-to-python3.md' - Migrating to systemd: 'installation/migrating-to-systemd.md' @@ -76,6 +82,3 @@ pages: - Version 1.2: 'release-notes/version-1.2.md' - Version 1.1: 'release-notes/version-1.1.md' - Version 1.0: 'release-notes/version-1.0.md' - -markdown_extensions: - - admonition: From 1b64f67f2b830e8913a0c05603126d0fd5f78710 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 26 Feb 2020 12:28:35 -0500 Subject: [PATCH 11/61] Update installation instructions to use a Python virtual environment --- base_requirements.txt | 4 +++ contrib/netbox-rq.service | 2 +- contrib/netbox.service | 2 +- docs/installation/3-netbox.md | 44 ++++++++++++++++-------------- docs/installation/4-http-daemon.md | 4 +-- requirements.txt | 1 + 6 files changed, 33 insertions(+), 24 deletions(-) diff --git a/base_requirements.txt b/base_requirements.txt index ed42b6c08..e1c29a2a2 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -98,3 +98,7 @@ redis # SVG image rendering (used for rack elevations) # https://github.com/mozman/svgwrite svgwrite + +# Python package management tool +# https://pythonwheels.com/ +wheel diff --git a/contrib/netbox-rq.service b/contrib/netbox-rq.service index 7a300a195..58662dccf 100644 --- a/contrib/netbox-rq.service +++ b/contrib/netbox-rq.service @@ -12,7 +12,7 @@ Group=www-data WorkingDirectory=/opt/netbox -ExecStart=/usr/bin/python3 /opt/netbox/netbox/manage.py rqworker +ExecStart=/opt/netbox/venv/bin/python3 /opt/netbox/netbox/manage.py rqworker Restart=on-failure RestartSec=30 diff --git a/contrib/netbox.service b/contrib/netbox.service index 3cc9069c6..076879254 100644 --- a/contrib/netbox.service +++ b/contrib/netbox.service @@ -12,7 +12,7 @@ Group=www-data PIDFile=/var/tmp/netbox.pid WorkingDirectory=/opt/netbox -ExecStart=/usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox --config /opt/netbox/gunicorn.py netbox.wsgi +ExecStart=/opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox --config /opt/netbox/gunicorn.py netbox.wsgi Restart=on-failure RestartSec=30 diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index 5c29b3996..b58391769 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -5,7 +5,7 @@ This section of the documentation discusses installing and configuring the NetBo #### Ubuntu ```no-highlight -# apt-get install -y python3 python3-pip python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev +# apt-get install -y python3 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev ``` #### CentOS @@ -73,31 +73,35 @@ Checking connectivity... done. `# chown -R netbox:netbox /opt/netbox/netbox/media/` -## Install Python Packages +## Set Up Python Environment -Install the required Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the system dependencies listed above.) +We'll use a Python [virtual environment](https://docs.python.org/3.6/tutorial/venv.html) to ensure NetBox's required packages don't conflict with anything in the base system. This will create a directory named `venv` in our NetBox root. ```no-highlight -# pip3 install -r requirements.txt +# python3 -m venv /opt/netbox/venv ``` -!!! note - If you encounter errors while installing the required packages, check that you're running a recent version of pip (v9.0.1 or higher) with the command `pip3 -V`. - -### NAPALM Automation (Optional) - -NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API. Installation of NAPALM is optional. To enable it, install the `napalm` package using pip or pip3: +Next, activate the virtual environment and install the required Python packages. You should see your console prompt change to indicate the active environment. (Activating the virtual environment updates your command shell to use the local copy of Python that we just installed for NetBox instead of the system's Python interpreter.) ```no-highlight -# pip3 install napalm +# source venv/bin/activate +(venv) # pip3 install -r requirements.txt ``` -### Remote File Storage (Optional) +#### NAPALM Automation (Optional) + +NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API. Installation of NAPALM is optional. To enable it, install the `napalm` package: + +```no-highlight +(venv) # pip3 install napalm +``` + +#### Remote File Storage (Optional) By default, NetBox will use the local filesystem to storage uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired backend](../../configuration/optional-settings/#storage_backend) in `configuration.py`. ```no-highlight -# pip3 install django-storages +(venv) # pip3 install django-storages ``` ## Configuration @@ -105,8 +109,8 @@ By default, NetBox will use the local filesystem to storage uploaded files. To u Move into the NetBox configuration directory and make a copy of `configuration.example.py` named `configuration.py`. ```no-highlight -# cd netbox/netbox/ -# cp configuration.example.py configuration.py +(venv) # cd netbox/netbox/ +(venv) # cp configuration.example.py configuration.py ``` Open `configuration.py` with your preferred editor and set the following variables: @@ -182,8 +186,8 @@ You may use the script located at `netbox/generate_secret_key.py` to generate a Before NetBox can run, we need to install the database schema. This is done by running `python3 manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example): ```no-highlight -# cd /opt/netbox/netbox/ -# python3 manage.py migrate +(venv) # cd /opt/netbox/netbox/ +(venv) # python3 manage.py migrate Operations to perform: Apply all migrations: dcim, sessions, admin, ipam, utilities, auth, circuits, contenttypes, extras, secrets, users Running migrations: @@ -201,7 +205,7 @@ If this step results in a PostgreSQL authentication error, ensure that the usern NetBox does not come with any predefined user accounts. You'll need to create a super user to be able to log into NetBox: ```no-highlight -# python3 manage.py createsuperuser +(venv) # python3 manage.py createsuperuser Username: admin Email address: admin@example.com Password: @@ -212,7 +216,7 @@ Superuser created successfully. ## Collect Static Files ```no-highlight -# python3 manage.py collectstatic --no-input +(venv) # python3 manage.py collectstatic --no-input 959 static files copied to '/opt/netbox/netbox/static'. ``` @@ -222,7 +226,7 @@ Superuser created successfully. At this point, NetBox should be able to run. We can verify this by starting a development instance: ```no-highlight -# python3 manage.py runserver 0.0.0.0:8000 --insecure +(venv) # python3 manage.py runserver 0.0.0.0:8000 --insecure Performing system checks... System check identified no issues (0 silenced). diff --git a/docs/installation/4-http-daemon.md b/docs/installation/4-http-daemon.md index c50d71e4c..43206b26e 100644 --- a/docs/installation/4-http-daemon.md +++ b/docs/installation/4-http-daemon.md @@ -104,10 +104,10 @@ To enable SSL, consider this guide on [securing Apache with Let's Encrypt](https ## gunicorn Installation -Install gunicorn: +Check that the Python virtual environment created in [the previous step](3-netbox.md#set-up-python-environment) is still active, and install the `gunicorn` Python package. (If the virtual environment is not active, activate it with the command `source /opt/netbox/venv/bin/activate`.) ```no-highlight -# pip3 install gunicorn +(venv) # pip3 install gunicorn ``` Copy `/opt/netbox/contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. We make a copy of this file to ensure that any changes to it do not get overwritten by a future upgrade. diff --git a/requirements.txt b/requirements.txt index b0b1b971d..3b04494ed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,3 +23,4 @@ pycryptodome==3.9.4 PyYAML==5.3 redis==3.3.11 svgwrite==1.3.1 +wheel==0.34.2 From 0851b97ba5445583a9c8452009b5b45a2e9f110e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 26 Feb 2020 13:54:07 -0500 Subject: [PATCH 12/61] Update the upgrade script & instructions to use a virtual environment --- base_requirements.txt | 4 +++ docs/installation/4-http-daemon.md | 15 ++------ docs/installation/upgrading.md | 26 ++++++++++---- requirements.txt | 1 + upgrade.sh | 58 +++++++++++++++--------------- 5 files changed, 55 insertions(+), 49 deletions(-) diff --git a/base_requirements.txt b/base_requirements.txt index e1c29a2a2..ab33b1c06 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -58,6 +58,10 @@ djangorestframework # https://github.com/axnsan12/drf-yasg drf-yasg[validation] +# WSGI HTTP server +# https://gunicorn.org/ +gunicorn + # Platform-agnostic template rendering engine # https://github.com/pallets/jinja Jinja2 diff --git a/docs/installation/4-http-daemon.md b/docs/installation/4-http-daemon.md index 43206b26e..065c63b12 100644 --- a/docs/installation/4-http-daemon.md +++ b/docs/installation/4-http-daemon.md @@ -102,15 +102,9 @@ To enable SSL, consider this guide on [securing Apache with Let's Encrypt](https !!! note Certain components of NetBox (such as the display of rack elevation diagrams) rely on the use of embedded objects. Ensure that your HTTP server configuration does not override the `X-Frame-Options` response header set by NetBox. -## gunicorn Installation +## gunicorn Configuration -Check that the Python virtual environment created in [the previous step](3-netbox.md#set-up-python-environment) is still active, and install the `gunicorn` Python package. (If the virtual environment is not active, activate it with the command `source /opt/netbox/venv/bin/activate`.) - -```no-highlight -(venv) # pip3 install gunicorn -``` - -Copy `/opt/netbox/contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. We make a copy of this file to ensure that any changes to it do not get overwritten by a future upgrade. +Copy `/opt/netbox/contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. (We make a copy of this file to ensure that any changes to it do not get overwritten by a future upgrade.) ```no-highlight # cd /opt/netbox @@ -119,7 +113,7 @@ Copy `/opt/netbox/contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. We make a c You may wish to edit this file to change the bound IP address or port number, or to make performance-related adjustments. -## systemd configuration +## systemd Configuration We'll use systemd to control the daemonization of NetBox services. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory: @@ -127,9 +121,6 @@ We'll use systemd to control the daemonization of NetBox services. First, copy ` # cp contrib/*.service /etc/systemd/system/ ``` -!!! note - These service files assume that gunicorn is installed at `/usr/local/bin/gunicorn`. If the output of `which gunicorn` indicates a different path, you'll need to correct the `ExecStart` path in both files. - Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time: ```no-highlight diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index e5cf93a28..101ec9c36 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -1,12 +1,12 @@ -# Review the Release Notes +## Review the Release Notes Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../../release-notes/) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the version in which the change went into effect. -# Install the Latest Code +## Install the Latest Code As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by cloning the `master` branch of the git repository. -## Option A: Download a Release +### Option A: Download a Release Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive. Extract it to your desired path. In this example, we'll use `/opt/netbox`. @@ -34,7 +34,7 @@ Be sure to replicate your uploaded media as well. (The exact action necessary wi Also make sure to copy over any reports that you've made. Note that if you made them in a separate directory (`/opt/netbox-reports` for example), then you will not need to copy them - the config file that you copied earlier will point to the correct location. ```no-highlight -# cp -r /opt/netbox-X.Y.X/netbox/reports /opt/netbox/netbox/reports/ +# cp -r /opt/netbox-X.Y.Z/netbox/reports /opt/netbox/netbox/reports/ ``` If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well: @@ -49,7 +49,7 @@ Copy the LDAP configuration if using LDAP: # cp netbox-X.Y.Z/netbox/netbox/ldap_config.py netbox/netbox/netbox/ldap_config.py ``` -## Option B: Clone the Git Repository (latest master release) +### Option B: Clone the Git Repository (latest master release) This guide assumes that NetBox is installed at `/opt/netbox`. Pull down the most recent iteration of the master branch: @@ -60,7 +60,19 @@ This guide assumes that NetBox is installed at `/opt/netbox`. Pull down the most # git status ``` -# Run the Upgrade Script +## Rebuild the Virtual Environment + +Destroy and recreate the Python virtual environment. This ensures that an up-to-date version of each dependency is installed while and that any obsolete packages are no longer present. + +```no-highlight +# cd /opt/netbox +# rm -rf venv +# python3 -m venv venv +# source venv/bin/activate +(venv) # pip3 install -r requirements.txt +``` + +## Run the Upgrade Script Once the new code is in place, run the upgrade script (which may need to be run as root depending on how your environment is configured). @@ -82,7 +94,7 @@ This script: This may occur due to semantic differences in environment, and can be safely ignored. Never attempt to create new migrations unless you are intentionally modifying the database schema. -# Restart the WSGI Service +## Restart the WSGI Service Finally, restart the WSGI services to run the new code. If you followed this guide for the initial installation, this is done using `systemctl: diff --git a/requirements.txt b/requirements.txt index 3b04494ed..d8e704874 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ django-taggit-serializer==0.1.7 django-timezone-field==4.0 djangorestframework==3.10.3 drf-yasg[validation]==1.17.0 +gunicorn==20.0.4 Jinja2==2.10.3 Markdown==2.6.11 netaddr==0.7.19 diff --git a/upgrade.sh b/upgrade.sh index 2ff585e8d..72e465661 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -1,52 +1,50 @@ #!/bin/bash # This script will prepare NetBox to run after the code has been upgraded to # its most recent release. -# -# Once the script completes, remember to restart the WSGI service (e.g. -# gunicorn or uWSGI). cd "$(dirname "$0")" +VIRTUALENV="$(pwd -P)/venv" -PYTHON="python3" -PIP="pip3" +# Remove the existing virtual environment (if any) +if [ -d "$VIRTUALENV" ]; then + COMMAND="rm -rf ${VIRTUALENV}" + echo "Removing old virtual environment..." + eval $COMMAND +fi -# Uninstall any Python packages which are no longer needed -COMMAND="${PIP} uninstall -r old_requirements.txt -y" -echo "Removing old Python packages ($COMMAND)..." +# Create a new virtual environment +COMMAND="/usr/bin/python3 -m venv ${VIRTUALENV}" +echo "Creating a new virtual environment at ${VIRTUALENV}..." eval $COMMAND -# Install any new Python packages -COMMAND="${PIP} install -r requirements.txt --upgrade" -echo "Updating required Python packages ($COMMAND)..." -eval $COMMAND +# Activate the virtual environment +source "${VIRTUALENV}/bin/activate" -# Validate Python dependencies -COMMAND="${PIP} check" -echo "Validating Python dependencies ($COMMAND)..." -eval $COMMAND || ( - echo "******** PLEASE FIX THE DEPENDENCIES BEFORE CONTINUING ********" - echo "* Manually install newer version(s) of the highlighted packages" - echo "* so that 'pip3 check' passes. For more information see:" - echo "* https://github.com/pypa/pip/issues/988" - exit 1 -) +# Install Python packages +COMMAND="pip3 install -r requirements.txt" +echo "Installing Python packages ($COMMAND)..." +eval $COMMAND # Apply any database migrations -COMMAND="${PYTHON} netbox/manage.py migrate" +COMMAND="python3 netbox/manage.py migrate" echo "Applying database migrations ($COMMAND)..." eval $COMMAND -# Delete any stale content types -COMMAND="${PYTHON} netbox/manage.py remove_stale_contenttypes --no-input" -echo "Removing stale content types ($COMMAND)..." -eval $COMMAND - # Collect static files -COMMAND="${PYTHON} netbox/manage.py collectstatic --no-input" +COMMAND="python3 netbox/manage.py collectstatic --no-input" echo "Collecting static files ($COMMAND)..." eval $COMMAND +# Delete any stale content types +COMMAND="python3 netbox/manage.py remove_stale_contenttypes --no-input" +echo "Removing stale content types ($COMMAND)..." +eval $COMMAND + # Clear all cached data -COMMAND="${PYTHON} netbox/manage.py invalidate all" +COMMAND="python3 netbox/manage.py invalidate all" echo "Clearing cache data ($COMMAND)..." eval $COMMAND + +echo "Upgrade complete! Don't forget to restart the NetBox services:" +echo " sudo systemctl restart netbox" +echo " sudo systemctl restart netbox-rq" From 2ee06c13f9bd38c0b4fa77282ee287a52112d215 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 26 Feb 2020 13:56:44 -0500 Subject: [PATCH 13/61] Remove Python 2 migration guide --- docs/installation/index.md | 2 -- docs/installation/migrating-to-python3.md | 38 ----------------------- mkdocs.yml | 1 - 3 files changed, 41 deletions(-) delete mode 100644 docs/installation/migrating-to-python3.md diff --git a/docs/installation/index.md b/docs/installation/index.md index 8df387ca8..082c94ec3 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -12,6 +12,4 @@ The following sections detail how to set up a new instance of NetBox: If you are upgrading from an existing installation, please consult the [upgrading guide](upgrading.md). -NetBox v2.5 and later requires Python 3.5 or higher. Please see the instructions for [migrating to Python 3](migrating-to-python3.md) if you are still using Python 2. - Netbox v2.5.9 and later moved to using systemd instead of supervisord. Please see the instructions for [migrating to systemd](migrating-to-systemd.md) if you are still using supervisord. diff --git a/docs/installation/migrating-to-python3.md b/docs/installation/migrating-to-python3.md deleted file mode 100644 index 2c2c2b917..000000000 --- a/docs/installation/migrating-to-python3.md +++ /dev/null @@ -1,38 +0,0 @@ -# Migration - -!!! warning - As of version 2.5, NetBox no longer supports Python 2. Python 3 is required to run any 2.5 release or later. - -## Ubuntu - -Remove the Python2 version of gunicorn: - -```no-highlight -# pip uninstall -y gunicorn -``` - -Install Python3 and pip3, Python's package management tool: - -```no-highlight -# apt-get update -# apt-get install -y python3 python3-dev python3-setuptools -# easy_install3 pip -``` - -Install the Python3 packages required by NetBox: - -```no-highlight -# pip3 install -r requirements.txt -``` - -Replace gunicorn with the Python3 version: - -```no-highlight -# pip3 install gunicorn -``` - -If using LDAP authentication, install the `django-auth-ldap` package: - -```no-highlight -# pip3 install django-auth-ldap -``` diff --git a/mkdocs.yml b/mkdocs.yml index 6de4b532f..cf45b89ae 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -17,7 +17,6 @@ nav: - 4. HTTP Daemon: 'installation/4-http-daemon.md' - 5. LDAP (Optional): 'installation/5-ldap.md' - Upgrading NetBox: 'installation/upgrading.md' - - Migrating to Python3: 'installation/migrating-to-python3.md' - Migrating to systemd: 'installation/migrating-to-systemd.md' - Configuration: - Configuring NetBox: 'configuration/index.md' From 015a339202193633f10cfd9e958407d56c87f639 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 26 Feb 2020 14:09:27 -0500 Subject: [PATCH 14/61] Update systemd migration guide --- docs/installation/migrating-to-systemd.md | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/docs/installation/migrating-to-systemd.md b/docs/installation/migrating-to-systemd.md index f5fcb7598..ff9e6cf0c 100644 --- a/docs/installation/migrating-to-systemd.md +++ b/docs/installation/migrating-to-systemd.md @@ -1,16 +1,17 @@ -# Migration - -Migration is not required, as supervisord will still continue to function. +This document contains instructions for migrating from a legacy NetBox deployment using [supervisor](http://supervisord.org/) to a systemd-based approach. ## Ubuntu -### Remove supervisord: +### Uninstall supervisord: ```no-highlight # apt-get remove -y supervisord ``` -### systemd configuration: +### Configure systemd: + +!!! note + These instructions assume the presence of a Python virtual environment at `/opt/netbox/venv`. If you have not created this environment, please refer to the [installation instructions](3-netbox.md#set-up-python-environment) for direction. We'll use systemd to control the daemonization of NetBox services. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory: @@ -19,10 +20,7 @@ We'll use systemd to control the daemonization of NetBox services. First, copy ` ``` !!! note - These service files assume that gunicorn is installed at `/usr/local/bin/gunicorn`. If the output of `which gunicorn` indicates a different path, you'll need to correct the `ExecStart` path in both files. - -!!! note - You may need to modify the user that the systemd service runs as. Please verify the user for httpd on your specific release and edit both files to match your httpd service under user and group. The username could be "nobody", "nginx", "apache", "www-data" or any number of other usernames. + You may need to modify the user that the systemd service runs as. Please verify the user for httpd on your specific release and edit both files to match your httpd service under user and group. The username could be "nobody", "nginx", "apache", "www-data", or something else. Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time: @@ -51,7 +49,7 @@ You can use the command `systemctl status netbox` to verify that the WSGI servic ... ``` -At this point, you should be able to connect to the HTTP service at the server name or IP address you provided. If you are unable to connect, check that the nginx service is running and properly configured. If you receive a 502 (bad gateway) error, this indicates that gunicorn is misconfigured or not running. +At this point, you should be able to connect to the HTTP service at the server name or IP address you provided. If you are unable to connect, check that the nginx service is running and properly configured. If you receive a 502 (bad gateway) error, this indicates that gunicorn is misconfigured or not running. Issue the command `journalctl -xe` to see why the services were unable to start. !!! info Please keep in mind that the configurations provided here are bare minimums required to get NetBox up and running. You may want to make adjustments to better suit your production environment. From 798ecfc8f0467d2cee47e617825bb4aa16c63a96 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 26 Feb 2020 14:09:54 -0500 Subject: [PATCH 15/61] Clean up the upgrade guide --- docs/installation/upgrading.md | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 101ec9c36..c7d9977cc 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -60,21 +60,9 @@ This guide assumes that NetBox is installed at `/opt/netbox`. Pull down the most # git status ``` -## Rebuild the Virtual Environment - -Destroy and recreate the Python virtual environment. This ensures that an up-to-date version of each dependency is installed while and that any obsolete packages are no longer present. - -```no-highlight -# cd /opt/netbox -# rm -rf venv -# python3 -m venv venv -# source venv/bin/activate -(venv) # pip3 install -r requirements.txt -``` - ## Run the Upgrade Script -Once the new code is in place, run the upgrade script (which may need to be run as root depending on how your environment is configured). +Once the new code is in place, run the upgrade script: ```no-highlight # ./upgrade.sh @@ -82,7 +70,8 @@ Once the new code is in place, run the upgrade script (which may need to be run This script: -* Installs or upgrades any new required Python packages +* Destroys and rebuilds the Python virtual environment +* Installs all required Python packages * Applies any database migrations that were included in the release * Collects all static files to be served by the HTTP service @@ -94,9 +83,9 @@ This script: This may occur due to semantic differences in environment, and can be safely ignored. Never attempt to create new migrations unless you are intentionally modifying the database schema. -## Restart the WSGI Service +## Restart the NetBox Services -Finally, restart the WSGI services to run the new code. If you followed this guide for the initial installation, this is done using `systemctl: +Finally, restart the gunicorn and RQ services: ```no-highlight # sudo systemctl restart netbox @@ -104,4 +93,4 @@ Finally, restart the WSGI services to run the new code. If you followed this gui ``` !!! note - It's possible you are still using supervisord instead of the linux native systemd. If you are still using supervisord you can restart the services by either restarting supervisord or by using supervisorctl to restart netbox. + It's possible you are still using supervisord instead of systemd. If so, please see the instructions for [migrating to systemd](migrating-to-systemd.md). From be9df3c07d3d7c4d36c125bc070080bcf1666e1d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 26 Feb 2020 14:38:25 -0500 Subject: [PATCH 16/61] Simplify the systemctl commands --- docs/installation/4-http-daemon.md | 6 ++---- docs/installation/migrating-to-systemd.md | 6 ++---- docs/installation/upgrading.md | 3 +-- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/docs/installation/4-http-daemon.md b/docs/installation/4-http-daemon.md index 065c63b12..7e19a6b69 100644 --- a/docs/installation/4-http-daemon.md +++ b/docs/installation/4-http-daemon.md @@ -125,10 +125,8 @@ Then, start the `netbox` and `netbox-rq` services and enable them to initiate at ```no-highlight # systemctl daemon-reload -# systemctl start netbox.service -# systemctl start netbox-rq.service -# systemctl enable netbox.service -# systemctl enable netbox-rq.service +# systemctl start netbox netbox-rq +# systemctl enable netbox netbox-rq ``` You can use the command `systemctl status netbox` to verify that the WSGI service is running: diff --git a/docs/installation/migrating-to-systemd.md b/docs/installation/migrating-to-systemd.md index ff9e6cf0c..34ce70bb6 100644 --- a/docs/installation/migrating-to-systemd.md +++ b/docs/installation/migrating-to-systemd.md @@ -26,10 +26,8 @@ Then, start the `netbox` and `netbox-rq` services and enable them to initiate at ```no-highlight # systemctl daemon-reload -# systemctl start netbox.service -# systemctl start netbox-rq.service -# systemctl enable netbox.service -# systemctl enable netbox-rq.service +# systemctl start netbox netbox-rq +# systemctl enable netbox netbox-rq ``` You can use the command `systemctl status netbox` to verify that the WSGI service is running: diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index c7d9977cc..b4f19a87d 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -88,8 +88,7 @@ This script: Finally, restart the gunicorn and RQ services: ```no-highlight -# sudo systemctl restart netbox -# sudo systemctl restart netbox-rq +# sudo systemctl restart netbox netbox-rq ``` !!! note From 1abc82e7185826e08ae185a6344e89cd084f2a8c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 26 Feb 2020 15:26:12 -0500 Subject: [PATCH 17/61] Update upgrade script & instructions to better accomodate moving to a venv --- docs/installation/upgrading.md | 3 +++ upgrade.sh | 19 ++++++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index b4f19a87d..bf6497f6d 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -85,6 +85,9 @@ This script: ## Restart the NetBox Services +!!! warning + If you are upgrading from an installation that does not use a Python virtual environment, you'll need to update the systemd service files to reference the new Python and gunicorn executables before restarting the services. These are located in `/opt/netbox/venv/bin/`. See the example service files in `/opt/netbox/contrib/` for reference. + Finally, restart the gunicorn and RQ services: ```no-highlight diff --git a/upgrade.sh b/upgrade.sh index 72e465661..f672d4c90 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -10,12 +10,18 @@ if [ -d "$VIRTUALENV" ]; then COMMAND="rm -rf ${VIRTUALENV}" echo "Removing old virtual environment..." eval $COMMAND +else + WARN_MISSING_VENV=1 fi # Create a new virtual environment COMMAND="/usr/bin/python3 -m venv ${VIRTUALENV}" echo "Creating a new virtual environment at ${VIRTUALENV}..." -eval $COMMAND +eval $COMMAND || ( + echo "Failed to create the virtual environment. Check that you have the" + echo "required system packages installed." + exit 1 +) # Activate the virtual environment source "${VIRTUALENV}/bin/activate" @@ -45,6 +51,13 @@ COMMAND="python3 netbox/manage.py invalidate all" echo "Clearing cache data ($COMMAND)..." eval $COMMAND +if [ WARN_MISSING_VENV ]; then + echo "No existing virtual environment was detected. A new one has been" + echo "created. Update your systemd service files to reflect the new" + echo "executables." + echo " Python: ${VIRTUALENV}/bin/python" + echo " gunicorn: ${VIRTUALENV}/bin/gunicorn" +fi + echo "Upgrade complete! Don't forget to restart the NetBox services:" -echo " sudo systemctl restart netbox" -echo " sudo systemctl restart netbox-rq" +echo " sudo systemctl restart netbox netbox-rq" From 64c9bf27c10e88a27b7f58c0ca8405bbf8606163 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 26 Feb 2020 15:34:42 -0500 Subject: [PATCH 18/61] Fix exit statement in upgrade script --- upgrade.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/upgrade.sh b/upgrade.sh index f672d4c90..aab7a6919 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -17,11 +17,11 @@ fi # Create a new virtual environment COMMAND="/usr/bin/python3 -m venv ${VIRTUALENV}" echo "Creating a new virtual environment at ${VIRTUALENV}..." -eval $COMMAND || ( +eval $COMMAND || { echo "Failed to create the virtual environment. Check that you have the" echo "required system packages installed." exit 1 -) +} # Activate the virtual environment source "${VIRTUALENV}/bin/activate" From 215dbef7a04c90f8f84720361bba1e0c7a736306 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 26 Feb 2020 15:40:05 -0500 Subject: [PATCH 19/61] Improved formatting of upgrade script messages --- upgrade.sh | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/upgrade.sh b/upgrade.sh index aab7a6919..977d9684d 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -18,8 +18,10 @@ fi COMMAND="/usr/bin/python3 -m venv ${VIRTUALENV}" echo "Creating a new virtual environment at ${VIRTUALENV}..." eval $COMMAND || { - echo "Failed to create the virtual environment. Check that you have the" - echo "required system packages installed." + echo "--------------------------------------------------------------------" + echo "ERROR: Failed to create the virtual environment. Check that you have" + echo "the required system packages installed." + echo "--------------------------------------------------------------------" exit 1 } @@ -52,11 +54,13 @@ echo "Clearing cache data ($COMMAND)..." eval $COMMAND if [ WARN_MISSING_VENV ]; then - echo "No existing virtual environment was detected. A new one has been" - echo "created. Update your systemd service files to reflect the new" + echo "--------------------------------------------------------------------" + echo "WARNING: No existing virtual environment was detected. A new one has" + echo "been created. Update your systemd service files to reflect the new" echo "executables." echo " Python: ${VIRTUALENV}/bin/python" echo " gunicorn: ${VIRTUALENV}/bin/gunicorn" + echo "--------------------------------------------------------------------" fi echo "Upgrade complete! Don't forget to restart the NetBox services:" From 56c26f80b306e143ba9f6d930e7663838934be86 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 26 Feb 2020 15:56:26 -0500 Subject: [PATCH 20/61] Changelog for #3949 --- docs/release-notes/version-2.7.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 62b85bc83..fe8a02190 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -1,5 +1,11 @@ # v2.7.9 (FUTURE) +**Note:** This release will deploy a Python virtual environment on upgrade in the `venv/` directory. This will require modifying the paths to your Python and gunicorn executables in the systemd service files. For more detail, please see the [upgrade instructions](https://netbox.readthedocs.io/en/stable/installation/upgrading/). + +## Enhancements + +* [#3949](https://github.com/netbox-community/netbox/issues/3949) - Revised the installation docs and upgrade script to employ a Python virtual environment + ## Bug Fixes * [#4277](https://github.com/netbox-community/netbox/issues/4277) - Fix filtering of clusters by tenant From 87c914bece5fa5425244c0c051370ff5f68dfa18 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 26 Feb 2020 15:59:26 -0500 Subject: [PATCH 21/61] Reorganize .gitignore; add /venv/ --- .gitignore | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 66a8b13e8..485b46d59 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.pyc +*.swp /netbox/netbox/configuration.py /netbox/netbox/ldap_config.py /netbox/reports/* @@ -6,15 +7,14 @@ /netbox/scripts/* !/netbox/scripts/__init__.py /netbox/static -.idea +/venv/ /*.sh !upgrade.sh fabfile.py -*.swp -gunicorn_config.py gunicorn.py netbox.log netbox.pid .DS_Store -.vscode +.idea .coverage +.vscode From 5a00939512fb4d61f5754be919652dc742c34e30 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 26 Feb 2020 16:41:59 -0500 Subject: [PATCH 22/61] Fixes #4285: Include A/Z termination sites in provider circuits table --- docs/release-notes/version-2.7.md | 1 + netbox/circuits/models.py | 2 ++ netbox/circuits/querysets.py | 15 +++++++++++++++ netbox/circuits/views.py | 13 +++++++------ netbox/netbox/views.py | 14 +++----------- 5 files changed, 28 insertions(+), 17 deletions(-) create mode 100644 netbox/circuits/querysets.py diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index fe8a02190..379151e2a 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -9,6 +9,7 @@ ## Bug Fixes * [#4277](https://github.com/netbox-community/netbox/issues/4277) - Fix filtering of clusters by tenant +* [#4285](https://github.com/netbox-community/netbox/issues/4285) - Include A/Z termination sites in provider circuits table --- diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 59f6e2004..812eaa79e 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -10,6 +10,7 @@ from extras.models import CustomFieldModel, ObjectChange, TaggedItem from utilities.models import ChangeLoggedModel from utilities.utils import serialize_object from .choices import * +from .querysets import CircuitQuerySet __all__ = ( @@ -184,6 +185,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel): object_id_field='obj_id' ) + objects = CircuitQuerySet.as_manager() tags = TaggableManager(through=TaggedItem) csv_headers = [ diff --git a/netbox/circuits/querysets.py b/netbox/circuits/querysets.py new file mode 100644 index 000000000..60956f32a --- /dev/null +++ b/netbox/circuits/querysets.py @@ -0,0 +1,15 @@ +from django.db.models import OuterRef, QuerySet, Subquery + + +class CircuitQuerySet(QuerySet): + + def annotate_sites(self): + """ + Annotate the A and Z termination site names for ordering. + """ + from circuits.models import CircuitTermination + _terminations = CircuitTermination.objects.filter(circuit=OuterRef('pk')) + return self.annotate( + a_side=Subquery(_terminations.filter(term_side='A').values('site__name')[:1]), + z_side=Subquery(_terminations.filter(term_side='Z').values('site__name')[:1]), + ) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index ba873f23f..b092e1855 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -37,10 +37,14 @@ class ProviderView(PermissionRequiredMixin, View): def get(self, request, slug): provider = get_object_or_404(Provider, slug=slug) - circuits = Circuit.objects.filter(provider=provider).prefetch_related('type', 'tenant', 'terminations__site') + circuits = Circuit.objects.filter( + provider=provider + ).prefetch_related( + 'type', 'tenant', 'terminations__site' + ).annotate_sites() show_graphs = Graph.objects.filter(type__model='provider').exists() - circuits_table = tables.CircuitTable(circuits, orderable=False) + circuits_table = tables.CircuitTable(circuits) circuits_table.columns.hide('provider') paginate = { @@ -142,10 +146,7 @@ class CircuitListView(PermissionRequiredMixin, ObjectListView): _terminations = CircuitTermination.objects.filter(circuit=OuterRef('pk')) queryset = Circuit.objects.prefetch_related( 'provider', 'type', 'tenant', 'terminations__site' - ).annotate( - a_side=Subquery(_terminations.filter(term_side='A').values('site__name')[:1]), - z_side=Subquery(_terminations.filter(term_side='Z').values('site__name')[:1]), - ) + ).annotate_sites() filterset = filters.CircuitFilterSet filterset_form = forms.CircuitFilterForm table = tables.CircuitTable diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 904dc7375..05bcea90d 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -1,6 +1,6 @@ from collections import OrderedDict -from django.db.models import Count, F, OuterRef, Subquery +from django.db.models import Count, F from django.shortcuts import render from django.views.generic import View from rest_framework.response import Response @@ -8,7 +8,7 @@ from rest_framework.reverse import reverse from rest_framework.views import APIView from circuits.filters import CircuitFilterSet, ProviderFilterSet -from circuits.models import Circuit, CircuitTermination, Provider +from circuits.models import Circuit, Provider from circuits.tables import CircuitTable, ProviderTable from dcim.filters import ( CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, PowerFeedFilterSet, RackFilterSet, RackGroupFilterSet, SiteFilterSet, @@ -50,15 +50,7 @@ SEARCH_TYPES = OrderedDict(( 'permission': 'circuits.view_circuit', 'queryset': Circuit.objects.prefetch_related( 'type', 'provider', 'tenant', 'terminations__site' - ).annotate( - # Annotate A/Z terminations - a_side=Subquery( - CircuitTermination.objects.filter(circuit=OuterRef('pk')).filter(term_side='A').values('site__name')[:1] - ), - z_side=Subquery( - CircuitTermination.objects.filter(circuit=OuterRef('pk')).filter(term_side='Z').values('site__name')[:1] - ), - ), + ).annotate_sites(), 'filterset': CircuitFilterSet, 'table': CircuitTable, 'url': 'circuits:circuit_list', From 624566b04e8212b73f8f94f5ad0ef912ca6295d6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 26 Feb 2020 16:49:39 -0500 Subject: [PATCH 23/61] Fixes #4282: Fix label on export button for device types --- docs/release-notes/version-2.7.md | 1 + netbox/utilities/templates/buttons/export.html | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 379151e2a..b314a8433 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -9,6 +9,7 @@ ## Bug Fixes * [#4277](https://github.com/netbox-community/netbox/issues/4277) - Fix filtering of clusters by tenant +* [#4282](https://github.com/netbox-community/netbox/issues/4282) - Fix label on export button for device types * [#4285](https://github.com/netbox-community/netbox/issues/4285) - Include A/Z termination sites in provider circuits table --- diff --git a/netbox/utilities/templates/buttons/export.html b/netbox/utilities/templates/buttons/export.html index ee76dae6c..15520c77b 100644 --- a/netbox/utilities/templates/buttons/export.html +++ b/netbox/utilities/templates/buttons/export.html @@ -4,8 +4,8 @@ Export -